Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add temporary machines support #134

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions api/hosts-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -409,20 +409,32 @@ 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
response.json({ error: 'Project not found' }, null, 2);
return;
}

const { user, session } = request;
if (!user) {
if (!session) {
return;
}

machines.spawnTemporary(session.id, projectId, (error, machine) => {
if (error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe log the error to know what's happening? E.g. log('[fail] creating temporary container', error).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's already logged inside _spawn in machines.js

response.statusCode = 500; // Internal Server Error
response.json({ error: 'Could not create container' }, null, 2);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might want to return after than, in order not to end a response twice.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whoops, good catch!

return;
}
response.json({
container: machine.docker.container
}, null, 2);
});
return;
}

machines.spawn(user, projectId, (error, machine) => {
if (error) {
log('[fail] could not spawn machine', error);
Expand Down
3 changes: 2 additions & 1 deletion app.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ 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');
Expand Down
3 changes: 2 additions & 1 deletion join.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ boot.executeInParallel([
boot.forwardHttp,
boot.ensureHttpsCertificates,
boot.ensureDockerTlsCertificates,
boot.verifyJanitorOAuth2Access
boot.verifyJanitorOAuth2Access,
boot.loadTasks,
], () => {
boot.registerDockerClient(() => {
log('[ok] joined cluster as [hostname = ' + hostname + ']');
Expand Down
12 changes: 12 additions & 0 deletions lib/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -345,3 +346,14 @@ exports.registerDockerClient = function (next) {
next();
});
};

exports.loadTasks = function (callback) {
tasks.addType('destroy-temporary', ({ session, container }) => {
const machines = require('./machines');
machines.destroyTemporary(session, container, log);
});
setInterval(() => {
tasks.check();
}, 60000);
callback();
};
148 changes: 122 additions & 26 deletions lib/machines.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const docker = require('./docker');
const log = require('./log');
const metrics = require('./metrics');
const streams = require('./streams');
const tasks = require('./tasks');

// Get an existing user machine with the given project and machine ID.
exports.getMachineById = function (user, projectId, machineId) {
Expand Down Expand Up @@ -185,6 +186,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);

Expand All @@ -207,7 +258,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;
}

Expand All @@ -220,27 +271,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) {
Expand All @@ -257,7 +290,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';
Expand All @@ -266,19 +299,55 @@ 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.
machine.status = 'new';
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');
Expand All @@ -297,7 +366,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) {
Expand Down Expand Up @@ -445,6 +514,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');
Expand Down
53 changes: 53 additions & 0 deletions lib/tasks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
const db = require('./db');
const log = require('./log');
let taskCounter = 0; // Used to generate unique task IDs
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I would really prefer tasks to be a separate pull request.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to #140


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 msSince1970 = date.getTime();
const taskId = `${type}-${msSince1970}-${taskCounter}`;

const task = {
id: taskId,
date: msSince1970,
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);
};
7 changes: 3 additions & 4 deletions templates/projects.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@ <h4>In just a single click, enter a fully-functional development environment. Ye
<div class="panel-heading">
<img class="project-icon" src="{{= project.icon in xmlattr}}" alt="{{= project.name in xmlattr}} Logo">
<h4 class="project-title">{{= project.name in html}}</h4>
<div class="project-actions">{{if user then {{
<form action="/api/hosts/{{= project.docker.host in uri}}/containers?project={{= id in id}}" class="ajax-form has-feedback is-submit" data-redirect-after-success="/contributions/" method="put">
<div class="project-actions">
<form action="/api/hosts/{{= project.docker.host in uri}}/containers?project={{= id in id}}" class="ajax-form has-feedback is-submit" method="put">
<button class="btn btn-primary" title="Create a new container for this project" type="submit">New Container</button>
</form>}} else {{
<a class="btn btn-primary" href="/login">New Container</a>}}}}
</form>
</div>
</div>
<div class="panel-body">
Expand Down