diff --git a/api/hosts-api.js b/api/hosts-api.js index 9a96bc28..2cdc875d 100644 --- a/api/hosts-api.js +++ b/api/hosts-api.js @@ -409,13 +409,6 @@ containersAPI.put({ title: 'Create a container', handler (request, response) { - const { user } = request; - if (!user) { - response.statusCode = 403; // Forbidden - response.json({ error: 'Unauthorized' }, null, 2); - return; - } - const projectId = request.query.project; if (!(projectId in db.get('projects'))) { response.statusCode = 404; // Not Found @@ -423,6 +416,24 @@ containersAPI.put({ return; } + const { user, session } = request; + if (!user) { + if (!session) { + return; + } + + machines.spawnTemporary(session.id, projectId, (error, machine) => { + if (error) { + response.statusCode = 500; // Internal Server Error + response.json({ error: 'Could not create container' }, null, 2); + } + response.json({ + container: machine.docker.container + }, null, 2); + }); + return; + } + machines.spawn(user, projectId, (error, machine) => { if (error) { log('[fail] could not spawn machine', error); diff --git a/app.js b/app.js index d3e58c74..44f779b9 100644 --- a/app.js +++ b/app.js @@ -18,8 +18,9 @@ const users = require('./lib/users'); boot.executeInParallel([ boot.forwardHttp, boot.ensureHttpsCertificates, - boot.ensureDockerTlsCertificates + boot.ensureDockerTlsCertificates, ], () => { + boot.loadTasks(); // You can customize these values in './db.json'. const hostname = db.get('hostname', 'localhost'); const https = db.get('https'); diff --git a/join.js b/join.js index 8cead4c5..b8970374 100644 --- a/join.js +++ b/join.js @@ -32,6 +32,8 @@ boot.executeInParallel([ boot.registerDockerClient(() => { log('[ok] joined cluster as [hostname = ' + hostname + ']'); + boot.loadTasks(); + const https = db.get('https'); const ports = db.get('ports'); const security = db.get('security'); diff --git a/lib/boot.js b/lib/boot.js index 255e2bb1..f5edb964 100644 --- a/lib/boot.js +++ b/lib/boot.js @@ -8,6 +8,7 @@ const certificates = require('./certificates'); const db = require('./db'); const log = require('./log'); const oauth2 = require('./oauth2'); +const tasks = require('./tasks'); const hostname = db.get('hostname', 'localhost'); @@ -345,3 +346,13 @@ exports.registerDockerClient = function (next) { next(); }); }; + +exports.loadTasks = function () { + tasks.addType('destroy-temporary', ({ session, container }) => { + const machines = require('./machines'); + machines.destroyTemporary(session, container, log); + }); + setInterval(() => { + tasks.check(); + }, 60000); +}; diff --git a/lib/machines.js b/lib/machines.js index 4d137f72..358612cb 100644 --- a/lib/machines.js +++ b/lib/machines.js @@ -8,6 +8,7 @@ const docker = require('./docker'); const log = require('./log'); const metrics = require('./metrics'); const streams = require('./streams'); +const tasks = require('./tasks'); // List available user machines for each project, create when necessary. exports.getAvailableMachines = function (user) { @@ -195,6 +196,56 @@ exports.spawn = function (user, projectId, callback) { const machine = getOrCreateNewMachine(user, projectId); + _spawn(machine, project, function (error, machine) { + if (error) { + callback(error); + return; + } + + const containerId = machine.docker.container; + // Quickly authorize the user's public SSH keys to access this container. + deploySSHAuthorizedKeys(user, machine, error => { + log('spawn-sshkeys', containerId.slice(0, 16), error || 'success'); + db.save(); + }); + + // Install all non-empty user configuration files into this container. + Object.keys(user.configurations).forEach(file => { + if (!user.configurations[file]) { + return; + } + exports.deployConfiguration(user, machine, file).then(() => { + log('spawn-config', file, containerId.slice(0, 16), 'success'); + }).catch(error => { + log('spawn-config', file, containerId.slice(0, 16), error); + }); + }); + callback(null, machine); + }); +}; + +// Instantiate a new temporary machine for a project. (Fast!) +exports.spawnTemporary = function (sessionId, projectId, callback) { + const machine = createNewTemporaryMachine(sessionId, projectId); + + _spawn(machine, getProject(projectId), (error, machine) => { + if (error) { + callback(error); + return; + } + + const destroyDate = new Date(Date.now()); + destroyDate.setHours(destroyDate.getHours() + 8); + tasks.add(destroyDate, 'destroy-temporary', { + session: sessionId, + container: machine.docker.container + }); + + callback(null, machine); + }); +}; + +function _spawn (machine, project, callback) { // Keep track of the last project update this machine will be based on. metrics.set(machine, 'updated', project.data.updated); @@ -217,7 +268,7 @@ exports.spawn = function (user, projectId, callback) { log('spawn', image, error); machine.status = 'start-failed'; db.save(); - callback(new Error('Unable to start machine for project: ' + projectId)); + callback(new Error('Unable to start machine for project: ' + project.id)); return; } @@ -230,27 +281,9 @@ exports.spawn = function (user, projectId, callback) { metrics.push(project, 'spawn-time', [ now, now - time ]); db.save(); - // Quickly authorize the user's public SSH keys to access this container. - deploySSHAuthorizedKeys(user, machine, error => { - log('spawn-sshkeys', container.id.slice(0, 16), error || 'success'); - db.save(); - }); - - // Install all non-empty user configuration files into this container. - Object.keys(user.configurations).forEach(file => { - if (!user.configurations[file]) { - return; - } - exports.deployConfiguration(user, machine, file).then(() => { - log('spawn-config', file, container.id.slice(0, 16), 'success'); - }).catch(error => { - log('spawn-config', file, container.id.slice(0, 16), error); - }); - }); - callback(null, machine); }); -}; +} // Destroy a given user machine and recycle its ports. exports.destroy = function (user, projectId, machineId, callback) { @@ -267,7 +300,7 @@ exports.destroy = function (user, projectId, machineId, callback) { return; } - const { container: containerId, host } = machine.docker; + const containerId = machine.docker.container; if (!containerId) { // This machine has no associated container, just recycle it as is. machine.status = 'new'; @@ -276,12 +309,9 @@ exports.destroy = function (user, projectId, machineId, callback) { return; } - log('destroy', containerId.slice(0, 16), 'started'); - docker.removeContainer({ host, container: containerId }, error => { + _destroy(machine, (error) => { if (error) { - log('destroy', containerId.slice(0, 16), error); callback(error); - return; } // Recycle the machine's name and ports. @@ -289,6 +319,45 @@ exports.destroy = function (user, projectId, machineId, callback) { machine.docker.container = ''; db.save(); callback(); + }); +}; + +exports.destroyTemporary = function (sessionId, containerId, callback) { + const machines = db.get('temporaryMachines')[sessionId]; + if (!machines) { + callback(new Error('No machines for session ' + sessionId)); + } + + const machineIndex = machines.findIndex(machine => + machine.docker.container === containerId); + + if (machineIndex === -1) { + callback(new Error('Wrong container ID')); + } + + _destroy(machines[machineIndex], (error) => { + if (error) { + callback(error); + } + + machines.splice(machineIndex, 1); + db.save(); + callback(); + }); +}; + +function _destroy (machine, callback) { + const { container: containerId, host } = machine.docker; + + log('destroy', containerId.slice(0, 16), 'started'); + docker.removeContainer({ host, container: containerId }, error => { + if (error) { + log('destroy', containerId.slice(0, 16), error); + callback(error); + return; + } + + callback(); if (!machine.docker.image) { log('destroy', containerId.slice(0, 16), 'success'); @@ -307,7 +376,7 @@ exports.destroy = function (user, projectId, machineId, callback) { db.save(); }); }); -}; +} // Install or overwrite a configuration file in all the user's containers. exports.deployConfigurationInAllContainers = function (user, file) { @@ -452,6 +521,33 @@ function getOrCreateNewMachine (user, projectId) { return machine; } +function createNewTemporaryMachine (sessionId, projectId) { + const project = getProject(projectId); + const temporaryMachines = db.get('temporaryMachines'); + if (!(sessionId in temporaryMachines)) { + temporaryMachines[sessionId] = []; + } + + const machines = temporaryMachines[sessionId]; + + const machine = { + properties: { + name: project.name + ' #' + machines.length, + }, + status: 'new', + docker: { + host: '', + container: '', + ports: {}, + logs: '' + }, + data: {} + }; + machines.push(machine); + + return machine; +} + // Get a unique available port starting from 42000. function getPort () { const ports = db.get('ports'); diff --git a/lib/tasks.js b/lib/tasks.js new file mode 100644 index 00000000..d6bbeb24 --- /dev/null +++ b/lib/tasks.js @@ -0,0 +1,53 @@ +const db = require('./db'); +const log = require('./log'); +let taskCounter = 0; // Used to generate unique task IDs + +exports.tasks = db.get('tasks'); +exports.taskTypes = new Map(); + +exports.check = function () { + const now = Date.now(); + for (const task of Object.values(exports.tasks)) { + if (now > Number(task.date)) { + exports.execute(task); + } + } +}; + +exports.addType = function (type, task) { + if (exports.taskTypes.has(type)) { + throw new Error('[fail] task', task, 'already exists'); + } + + exports.taskTypes.set(type, task); +}; + +exports.add = function (date, type, data) { + taskCounter++; + + const dateHash = date.getTime(); + const taskId = `${type}-${dateHash}-${taskCounter}`; + + const task = { + id: taskId, + date: dateHash, + type, + data, + }; + + exports.tasks[taskId] = task; + db.save(); + return task; +}; + +exports.remove = function (id) { + delete exports.tasks[id]; + db.save(); +}; + +exports.execute = function ({ type, data, id }) { + const task = exports.taskTypes.get(type); + log("[ok] Task", id, "executed", data); + task(data); + exports.remove(id); +}; diff --git a/templates/projects.html b/templates/projects.html index 4d2f7460..749c3f0d 100644 --- a/templates/projects.html +++ b/templates/projects.html @@ -9,11 +9,10 @@