scheduler.json gets nuked during updates. When the box code restarts, the scheduler is unable to remove old container because the state file scheduler.json is now gone. It proceeds to create new container but that does not work because of name conflict. Fixes #531
355 lines
13 KiB
JavaScript
355 lines
13 KiB
JavaScript
'use strict';
|
|
|
|
var addons = require('./addons.js'),
|
|
async = require('async'),
|
|
assert = require('assert'),
|
|
config = require('./config.js'),
|
|
debug = require('debug')('box:src/docker.js'),
|
|
Docker = require('dockerode'),
|
|
safe = require('safetydance'),
|
|
semver = require('semver'),
|
|
util = require('util'),
|
|
_ = require('underscore');
|
|
|
|
exports = module.exports = {
|
|
connection: connectionInstance(),
|
|
downloadImage: downloadImage,
|
|
createContainer: createContainer,
|
|
startContainer: startContainer,
|
|
stopContainer: stopContainer,
|
|
stopContainerByName: stopContainer,
|
|
stopContainers: stopContainers,
|
|
deleteContainer: deleteContainer,
|
|
deleteContainerByName: deleteContainer,
|
|
deleteImage: deleteImage,
|
|
deleteContainers: deleteContainers,
|
|
createSubcontainer: createSubcontainer
|
|
};
|
|
|
|
function connectionInstance() {
|
|
var docker;
|
|
|
|
if (process.env.BOX_ENV === 'test') {
|
|
// test code runs a docker proxy on this port
|
|
docker = new Docker({ host: 'http://localhost', port: 5687 });
|
|
|
|
// proxy code uses this to route to the real docker
|
|
docker.options = { socketPath: '/var/run/docker.sock' };
|
|
} else {
|
|
docker = new Docker({ socketPath: '/var/run/docker.sock' });
|
|
}
|
|
|
|
return docker;
|
|
}
|
|
|
|
function debugApp(app, args) {
|
|
assert(!app || typeof app === 'object');
|
|
|
|
var prefix = app ? (app.location || '(bare)') : '(no app)';
|
|
debug(prefix + ' ' + util.format.apply(util, Array.prototype.slice.call(arguments, 1)));
|
|
}
|
|
|
|
function targetBoxVersion(manifest) {
|
|
if ('targetBoxVersion' in manifest) return manifest.targetBoxVersion;
|
|
|
|
if ('minBoxVersion' in manifest) return manifest.minBoxVersion;
|
|
|
|
return '0.0.1';
|
|
}
|
|
|
|
function pullImage(manifest, callback) {
|
|
var docker = exports.connection;
|
|
|
|
docker.pull(manifest.dockerImage, function (err, stream) {
|
|
if (err) return callback(new Error('Error connecting to docker. statusCode: %s' + err.statusCode));
|
|
|
|
// https://github.com/dotcloud/docker/issues/1074 says each status message
|
|
// is emitted as a chunk
|
|
stream.on('data', function (chunk) {
|
|
var data = safe.JSON.parse(chunk) || { };
|
|
debug('pullImage %s: %j', manifest.id, data);
|
|
|
|
// The information here is useless because this is per layer as opposed to per image
|
|
if (data.status) {
|
|
} else if (data.error) {
|
|
debug('pullImage error %s: %s', manifest.id, data.errorDetail.message);
|
|
}
|
|
});
|
|
|
|
stream.on('end', function () {
|
|
debug('downloaded image %s of %s successfully', manifest.dockerImage, manifest.id);
|
|
|
|
var image = docker.getImage(manifest.dockerImage);
|
|
|
|
image.inspect(function (err, data) {
|
|
if (err) return callback(new Error('Error inspecting image:' + err.message));
|
|
if (!data || !data.Config) return callback(new Error('Missing Config in image:' + JSON.stringify(data, null, 4)));
|
|
if (!data.Config.Entrypoint && !data.Config.Cmd) return callback(new Error('Only images with entry point are allowed'));
|
|
|
|
debug('This image of %s exposes ports: %j', manifest.id, data.Config.ExposedPorts);
|
|
|
|
callback(null);
|
|
});
|
|
});
|
|
|
|
stream.on('error', function (error) {
|
|
debug('error pulling image %s of %s: %j', manifest.dockerImage, manifest.id, error);
|
|
|
|
callback(error);
|
|
});
|
|
});
|
|
}
|
|
|
|
function downloadImage(manifest, callback) {
|
|
assert.strictEqual(typeof manifest, 'object');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
debug('downloadImage %s %s', manifest.id, manifest.dockerImage);
|
|
|
|
var attempt = 1;
|
|
|
|
async.retry({ times: 10, interval: 15000 }, function (retryCallback) {
|
|
debug('Downloading image %s %s. attempt: %s', manifest.id, manifest.dockerImage, attempt++);
|
|
|
|
pullImage(manifest, function (error) {
|
|
if (error) console.error(error);
|
|
|
|
retryCallback(error);
|
|
});
|
|
}, callback);
|
|
}
|
|
|
|
function createSubcontainer(app, name, cmd, options, callback) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof name, 'string');
|
|
assert(!cmd || util.isArray(cmd));
|
|
assert.strictEqual(typeof options, 'object');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
var docker = exports.connection,
|
|
isAppContainer = !cmd;
|
|
|
|
var manifest = app.manifest;
|
|
var exposedPorts = {}, dockerPortBindings = { };
|
|
var stdEnv = [
|
|
'CLOUDRON=1',
|
|
'WEBADMIN_ORIGIN=' + config.adminOrigin(),
|
|
'API_ORIGIN=' + config.adminOrigin(),
|
|
'APP_ORIGIN=https://' + config.appFqdn(app.location),
|
|
'APP_DOMAIN=' + config.appFqdn(app.location)
|
|
];
|
|
|
|
// docker portBindings requires ports to be exposed
|
|
exposedPorts[manifest.httpPort + '/tcp'] = {};
|
|
|
|
dockerPortBindings[manifest.httpPort + '/tcp'] = [ { HostIp: '127.0.0.1', HostPort: app.httpPort + '' } ];
|
|
|
|
var portEnv = [];
|
|
for (var e in app.portBindings) {
|
|
var hostPort = app.portBindings[e];
|
|
var containerPort = manifest.tcpPorts[e].containerPort || hostPort;
|
|
|
|
exposedPorts[containerPort + '/tcp'] = {};
|
|
portEnv.push(e + '=' + hostPort);
|
|
|
|
dockerPortBindings[containerPort + '/tcp'] = [ { HostIp: '0.0.0.0', HostPort: hostPort + '' } ];
|
|
}
|
|
|
|
var memoryLimit = manifest.memoryLimit || 1024 * 1024 * 200; // 200mb by default
|
|
// for subcontainers, this should ideally be false. but docker does not allow network sharing if the app container is not running
|
|
// this means cloudron exec does not work
|
|
var isolatedNetworkNs = true;
|
|
|
|
addons.getEnvironment(app, function (error, addonEnv) {
|
|
if (error) return callback(new Error('Error getting addon environment : ' + error));
|
|
|
|
var containerOptions = {
|
|
name: name, // used for filtering logs
|
|
// do _not_ set hostname to app fqdn. doing so sets up the dns name to look up the internal docker ip. this makes curl from within container fail
|
|
// for subcontainers, this should not be set because we already share the network namespace with app container
|
|
Hostname: isolatedNetworkNs ? (semver.gte(targetBoxVersion(app.manifest), '0.0.77') ? app.location : config.appFqdn(app.location)) : null,
|
|
Tty: isAppContainer,
|
|
Image: app.manifest.dockerImage,
|
|
Cmd: cmd,
|
|
Env: stdEnv.concat(addonEnv).concat(portEnv),
|
|
ExposedPorts: isAppContainer ? exposedPorts : { },
|
|
Volumes: { // see also ReadonlyRootfs
|
|
'/tmp': {},
|
|
'/run': {}
|
|
},
|
|
Labels: {
|
|
"location": app.location,
|
|
"appId": app.id,
|
|
"isSubcontainer": String(!isAppContainer)
|
|
},
|
|
HostConfig: {
|
|
Binds: addons.getBindsSync(app, app.manifest.addons),
|
|
Memory: memoryLimit / 2,
|
|
MemorySwap: memoryLimit, // Memory + Swap
|
|
PortBindings: isAppContainer ? dockerPortBindings : { },
|
|
PublishAllPorts: false,
|
|
ReadonlyRootfs: semver.gte(targetBoxVersion(app.manifest), '0.0.66'), // see also Volumes in startContainer
|
|
RestartPolicy: {
|
|
"Name": isAppContainer ? "always" : "no",
|
|
"MaximumRetryCount": 0
|
|
},
|
|
CpuShares: 512, // relative to 1024 for system processes
|
|
VolumesFrom: isAppContainer ? null : [ app.containerId + ":rw" ],
|
|
NetworkMode: isolatedNetworkNs ? 'default' : ('container:' + app.containerId), // share network namespace with parent
|
|
Links: isolatedNetworkNs ? addons.getLinksSync(app, app.manifest.addons) : null, // links is redundant with --net=container
|
|
SecurityOpt: config.CLOUDRON ? [ "apparmor:docker-cloudron-app" ] : null // profile available only on cloudron
|
|
}
|
|
};
|
|
containerOptions = _.extend(containerOptions, options);
|
|
|
|
// older versions wanted a writable /var/log
|
|
if (semver.lte(targetBoxVersion(app.manifest), '0.0.71')) containerOptions.Volumes['/var/log'] = {};
|
|
|
|
debugApp(app, 'Creating container for %s with options %j', app.manifest.dockerImage, containerOptions);
|
|
|
|
docker.createContainer(containerOptions, callback);
|
|
});
|
|
}
|
|
|
|
function createContainer(app, callback) {
|
|
createSubcontainer(app, app.id /* name */, null /* cmd */, { } /* options */, callback);
|
|
}
|
|
|
|
function startContainer(containerId, callback) {
|
|
assert.strictEqual(typeof containerId, 'string');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
var docker = exports.connection;
|
|
|
|
var container = docker.getContainer(containerId);
|
|
debug('Starting container %s', containerId);
|
|
|
|
container.start(function (error) {
|
|
if (error && error.statusCode !== 304) return callback(new Error('Error starting container :' + error));
|
|
|
|
return callback(null);
|
|
});
|
|
}
|
|
|
|
function stopContainer(containerId, callback) {
|
|
assert(!containerId || typeof containerId === 'string');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
if (!containerId) {
|
|
debug('No previous container to stop');
|
|
return callback();
|
|
}
|
|
|
|
var docker = exports.connection;
|
|
var container = docker.getContainer(containerId);
|
|
debug('Stopping container %s', containerId);
|
|
|
|
var options = {
|
|
t: 10 // wait for 10 seconds before killing it
|
|
};
|
|
|
|
container.stop(options, function (error) {
|
|
if (error && (error.statusCode !== 304 && error.statusCode !== 404)) return callback(new Error('Error stopping container:' + error));
|
|
|
|
debug('Waiting for container ' + containerId);
|
|
|
|
container.wait(function (error, data) {
|
|
if (error && (error.statusCode !== 304 && error.statusCode !== 404)) return callback(new Error('Error waiting on container:' + error));
|
|
|
|
debug('Container %s stopped with status code [%s]', containerId, data ? String(data.StatusCode) : '');
|
|
|
|
return callback(null);
|
|
});
|
|
});
|
|
}
|
|
|
|
function deleteContainer(containerId, callback) {
|
|
assert(!containerId || typeof containerId === 'string');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
debug('deleting container %s', containerId);
|
|
|
|
if (containerId === null) return callback(null);
|
|
|
|
var docker = exports.connection;
|
|
var container = docker.getContainer(containerId);
|
|
|
|
var removeOptions = {
|
|
force: true, // kill container if it's running
|
|
v: true // removes volumes associated with the container (but not host mounts)
|
|
};
|
|
|
|
container.remove(removeOptions, function (error) {
|
|
if (error && error.statusCode === 404) return callback(null);
|
|
|
|
if (error) debug('Error removing container %s : %j', containerId, error);
|
|
|
|
callback(error);
|
|
});
|
|
}
|
|
|
|
function deleteContainers(appId, callback) {
|
|
assert.strictEqual(typeof appId, 'string');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
var docker = exports.connection;
|
|
|
|
debug('deleting containers of %s', appId);
|
|
|
|
docker.listContainers({ all: 1, filters: JSON.stringify({ label: [ 'appId=' + appId ] }) }, function (error, containers) {
|
|
if (error) return callback(error);
|
|
|
|
async.eachSeries(containers, function (container, iteratorDone) {
|
|
deleteContainer(container.Id, iteratorDone);
|
|
}, callback);
|
|
});
|
|
}
|
|
|
|
function stopContainers(appId, callback) {
|
|
assert.strictEqual(typeof appId, 'string');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
var docker = exports.connection;
|
|
|
|
debug('stopping containers of %s', appId);
|
|
|
|
docker.listContainers({ all: 1, filters: JSON.stringify({ label: [ 'appId=' + appId ] }) }, function (error, containers) {
|
|
if (error) return callback(error);
|
|
|
|
async.eachSeries(containers, function (container, iteratorDone) {
|
|
stopContainer(container.Id, iteratorDone);
|
|
}, callback);
|
|
});
|
|
}
|
|
|
|
function deleteImage(manifest, callback) {
|
|
assert(!manifest || typeof manifest === 'object');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
var dockerImage = manifest ? manifest.dockerImage : null;
|
|
if (!dockerImage) return callback(null);
|
|
|
|
var docker = exports.connection;
|
|
|
|
docker.getImage(dockerImage).inspect(function (error, result) {
|
|
if (error && error.statusCode === 404) return callback(null);
|
|
|
|
if (error) return callback(error);
|
|
|
|
var removeOptions = {
|
|
force: true,
|
|
noprune: false
|
|
};
|
|
|
|
// delete image by id because 'docker pull' pulls down all the tags and this is the only way to delete all tags
|
|
docker.getImage(result.Id).remove(removeOptions, function (error) {
|
|
if (error && error.statusCode === 404) return callback(null);
|
|
if (error && error.statusCode === 409) return callback(null); // another container using the image
|
|
|
|
if (error) debug('Error removing image %s : %j', dockerImage, error);
|
|
|
|
callback(error);
|
|
});
|
|
});
|
|
}
|