diff --git a/.eslintrc-node.js b/.eslintrc-node.js deleted file mode 100644 index f6c64e35..00000000 --- a/.eslintrc-node.js +++ /dev/null @@ -1,26 +0,0 @@ -module.exports = { - "env": { - "browser": false, - "es6": true, - "node": true - }, - "extends": [ - "eslint:recommended", - "plugin:node/recommended", - "standard" - ], - "plugins": [ - "node" - ], - "rules": { - // Override some of standard js rules - "semi": ["error", "always"], - "comma-dangle": ["error", "only-multiline"], - "camelcase": "off", - "no-var": "error", - "prefer-const": "error", - - // Override some eslint base rules because we're using node. - "no-console": "off", - } -}; \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..ede84a76 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,30 @@ +module.exports = { + 'env': { + 'browser': false, + 'es6': true, + 'node': true + }, + 'extends': [ + 'eslint:recommended', + 'plugin:node/recommended', + 'standard' + ], + 'plugins': [ + 'node' + ], + 'rules': { + // Override some of standard js rules + 'semi': [ 'error', 'always' ], + 'comma-dangle': [ 'error', 'only-multiline' ], + 'camelcase': 'off', + 'no-var': 'error', + 'prefer-const': 'error', + 'arrow-parens': [ 'error', 'as-needed' ], + 'standard/array-bracket-even-spacing': 'off', + 'array-bracket-spacing': [ 'error', 'always', { 'objectsInArrays': false }], + 'object-curly-spacing': [ 'error', 'always' ], + + // Override some eslint base rules because we're using node. + 'no-console': 'off', + } +}; diff --git a/api/hosts-api.js b/api/hosts-api.js index d08b60c3..4c13eb1e 100644 --- a/api/hosts-api.js +++ b/api/hosts-api.js @@ -271,7 +271,7 @@ credentialsAPI.delete({ return; } - hosts.resetOAuth2ClientSecret(host, (error) => { + hosts.resetOAuth2ClientSecret(host, error => { if (error) { response.statusCode = 500; // Internal Server Error response.json({ error: 'Could not reset host credentials' }, null, 2); @@ -292,7 +292,7 @@ hostAPI.get('/images', { title: 'List host images', description: 'List the Docker images available on this host.', - handler: (request, response) => { + handler: async (request, response) => { const { user } = request; if (!users.isAdmin(user)) { response.statusCode = 404; // Not Found @@ -307,15 +307,15 @@ hostAPI.get('/images', { return; } - docker.listImages({ host: hostname }, (error, images) => { - if (error) { - log('[fail] host images', error); - response.statusCode = 503; // Service Unavailable - response.json({ error: 'Host unreachable' }, null, 2); - return; - } + try { + const images = await docker.listImages({ host: hostname }); + response.json(images, null, 2); - }); + } catch (error) { + log('[fail] host images', error); + response.statusCode = 503; // Service Unavailable + response.json({ error: 'Host unreachable' }, null, 2); + } }, examples: [], @@ -324,7 +324,7 @@ hostAPI.get('/images', { hostAPI.get('/version', { title: 'Show host version', - handler: (request, response) => { + handler: async (request, response) => { const { user } = request; if (!users.isAdmin(user)) { response.statusCode = 404; // Not Found @@ -339,15 +339,14 @@ hostAPI.get('/version', { return; } - docker.version({ host: hostname }, (error, version) => { - if (error) { - log('[fail] host version', error); - response.statusCode = 503; // Service Unavailable - response.json({ error: 'Host unreachable' }, null, 2); - return; - } + try { + const version = await docker.version({ host: hostname }); response.json({ docker: version }, null, 2); - }); + } catch (error) { + log('[fail] host version', error); + response.statusCode = 503; // Service Unavailable + response.json({ error: 'Host unreachable' }, null, 2); + } }, examples: [{ @@ -375,7 +374,7 @@ containersAPI.get({ title: 'List containers', description: 'List all Docker containers on this host.', - handler: (request, response) => { + handler: async (request, response) => { const { user } = request; if (!users.isAdmin(user)) { response.statusCode = 404; // Not Found @@ -390,15 +389,14 @@ containersAPI.get({ return; } - docker.listContainers({ host: hostname }, (error, containers) => { - if (error) { - log('[fail] host containers', error); - response.statusCode = 503; // Service Unavailable - response.json({ error: 'Host unreachable' }, null, 2); - return; - } + try { + const containers = await docker.listContainers({ host: hostname }); response.json(containers, null, 2); - }); + } catch (error) { + log('[fail] host containers', error); + response.statusCode = 503; // Service Unavailable + response.json({ error: 'Host unreachable' }, null, 2); + } }, examples: [], @@ -407,7 +405,7 @@ containersAPI.get({ containersAPI.put({ title: 'Create a container', - handler (request, response) { + async handler (request, response) { const { user } = request; if (!user) { response.statusCode = 403; // Forbidden @@ -422,17 +420,15 @@ containersAPI.put({ return; } - machines.spawn(user, projectId, (error, machine) => { - if (error) { - log('[fail] could not spawn machine', error); - response.statusCode = 500; // Internal Server Error - response.json({ error: 'Could not create new container' }, null, 2); - return; - } - + try { + const machine = await machines.spawn(user, projectId); response.statusCode = 201; // Created response.json({ container: machine.docker.container }, null, 2); - }); + } catch (error) { + log('[fail] could not spawn machine', error); + response.statusCode = 500; // Internal Server Error + response.json({ error: 'Could not create new container' }, null, 2); + } }, examples: [] @@ -581,10 +577,10 @@ containerAPI.delete({ containerAPI.get('/changes', { title: 'List changed files in a container', description: - 'List all files that were modified (Kind: 0), added (1) or deleted (2) ' + - 'in a given Docker container.', + 'List all files that were modified (Kind: 0), added (1) or deleted (2) ' + + 'in a given Docker container.', - handler: (request, response) => { + handler: async (request, response) => { const { user } = request; if (!user) { response.statusCode = 403; // Forbidden @@ -609,16 +605,14 @@ containerAPI.get('/changes', { } const parameters = { host: hostname, container }; - docker.listChangedFilesInContainer(parameters, (error, changedFiles) => { - if (error) { - log('[fail] container changes', error); - response.statusCode = 503; // Service Unavailable - response.json({ error: 'Host unreachable' }, null, 2); - return; - } - + try { + const changedFiles = await docker.listChangedFilesInContainer(parameters); response.json(changedFiles, null, 2); - }); + } catch (error) { + log('[fail] container changes', error); + response.statusCode = 503; // Service Unavailable + response.json({ error: 'Host unreachable' }, null, 2); + } }, examples: [{ diff --git a/app.js b/app.js index 9fc8605b..35e10d62 100644 --- a/app.js +++ b/app.js @@ -15,490 +15,492 @@ const machines = require('./lib/machines'); const routes = require('./lib/routes'); const users = require('./lib/users'); -boot.executeInParallel([ - boot.forwardHttp, - boot.ensureHttpsCertificates, - boot.ensureDockerTlsCertificates -], () => { - // You can customize these values in './db.json'. - const hostname = db.get('hostname', 'localhost'); - const https = db.get('https'); - const ports = db.get('ports'); - const security = db.get('security'); - - // The main Janitor server. - const app = camp.start({ - documentRoot: process.cwd() + '/static', - saveRequestChunks: true, - port: ports.https, - secure: !security.forceHttp, - key: https.key, - cert: https.crt, - ca: https.ca - }); +Promise.all([ + boot.forwardHttp(), + boot.ensureHttpsCertificates(), + boot.ensureDockerTlsCertificates() +]) + .catch(error => { log('[fail] could not start app', error); }) + .then(() => { + // You can customize these values in './db.json'. + const hostname = db.get('hostname', 'localhost'); + const https = db.get('https'); + const ports = db.get('ports'); + const security = db.get('security'); + + // The main Janitor server. + const app = camp.start({ + documentRoot: process.cwd() + '/static', + saveRequestChunks: true, + port: ports.https, + secure: !security.forceHttp, + key: https.key, + cert: https.crt, + ca: https.ca + }); - log('[ok] Janitor → http' + (security.forceHttp ? '' : 's') + '://' + - hostname + ':' + ports.https); + log('[ok] Janitor → http' + (security.forceHttp ? '' : 's') + '://' + + hostname + ':' + ports.https); - // Protect the server and its users with a security policies middleware. - const enforceSecurityPolicies = (request, response, next) => { - // Only accept requests addressed to our actual hostname. - const requestedHostname = request.headers.host; - if (requestedHostname !== hostname) { - routes.drop(response, 'invalid hostname: ' + requestedHostname); - return; - } + // Protect the server and its users with a security policies middleware. + const enforceSecurityPolicies = (request, response, next) => { + // Only accept requests addressed to our actual hostname. + const requestedHostname = request.headers.host; + if (requestedHostname !== hostname) { + routes.drop(response, 'invalid hostname: ' + requestedHostname); + return; + } - // Tell browsers to only use secure HTTPS connections for this web app. - response.setHeader('Strict-Transport-Security', 'max-age=31536000'); + // Tell browsers to only use secure HTTPS connections for this web app. + response.setHeader('Strict-Transport-Security', 'max-age=31536000'); - // Prevent browsers from accidentally seeing scripts where they shouldn't. - response.setHeader('X-Content-Type-Options', 'nosniff'); + // Prevent browsers from accidentally seeing scripts where they shouldn't. + response.setHeader('X-Content-Type-Options', 'nosniff'); - // Tell browsers this web app should never be embedded into an iframe. - response.setHeader('X-Frame-Options', 'DENY'); + // Tell browsers this web app should never be embedded into an iframe. + response.setHeader('X-Frame-Options', 'DENY'); - next(); - }; + next(); + }; - if (!security.forceInsecure) { - app.handle(enforceSecurityPolicies); - } else { - log('[warning] disabled all https security policies'); - } + if (!security.forceInsecure) { + app.handle(enforceSecurityPolicies); + } else { + log('[warning] disabled all https security policies'); + } - // Authenticate signed-in user requests and sessions with a server middleware. - app.handle((request, response, next) => { - users.get(request, (user, session) => { - request.session = session; - request.user = user; + // Authenticate signed-in user requests and sessions with a server middleware. + app.handle((request, response, next) => { + users.get(request, (user, session) => { + request.session = session; + request.user = user; + next(); + }); + }); + + // Authenticate OAuth2 requests with a server middleware. + app.handle((request, response, next) => { + request.oauth2scope = users.getOAuth2ScopeWithUser(request); next(); }); - }); - // Authenticate OAuth2 requests with a server middleware. - app.handle((request, response, next) => { - request.oauth2scope = users.getOAuth2ScopeWithUser(request); - next(); - }); + // Mount the Janitor API. + selfapi(app, '/api', api); - // Mount the Janitor API. - selfapi(app, '/api', api); + // Public landing page. + app.route(/^\/$/, (data, match, end, query) => { + const { user } = query.req; + routes.landingPage(query.res, user); + }); - // Public landing page. - app.route(/^\/$/, (data, match, end, query) => { - const { user } = query.req; - routes.landingPage(query.res, user); - }); + // Public API (when wrongly used with a trailing '/'). + app.route(/^\/api\/(.+)\/$/, (data, match, end, query) => { + routes.redirect(query.res, '/api/' + match[1]); + }); - // Public API (when wrongly used with a trailing '/'). - app.route(/^\/api\/(.+)\/$/, (data, match, end, query) => { - routes.redirect(query.res, '/api/' + match[1]); - }); + // Public API reference. + app.route(/^\/reference\/api\/?$/, (data, match, end, query) => { + const { user } = query.req; + log('api reference'); + routes.apiPage(query.res, api, user); + }); - // Public API reference. - app.route(/^\/reference\/api\/?$/, (data, match, end, query) => { - const { user } = query.req; - log('api reference'); - routes.apiPage(query.res, api, user); - }); + // New Public API reference. + app.route(/^\/reference\/api-new\/?$/, (data, match, end, query) => { + const { user } = query.req; + log('api reference'); + routes.apiPageNew(query.res, api, user); + }); - // New Public API reference. - app.route(/^\/reference\/api-new\/?$/, (data, match, end, query) => { - const { user } = query.req; - log('api reference'); - routes.apiPageNew(query.res, api, user); - }); + // Public blog page. + app.route(/^\/blog\/?$/, (data, match, end, query) => { + const { user } = query.req; + log('blog'); + routes.blogPage(query.res, user); + }); - // Public blog page. - app.route(/^\/blog\/?$/, (data, match, end, query) => { - const { user } = query.req; - log('blog'); - routes.blogPage(query.res, user); - }); + // New public blog page. + app.route(/^\/blog-new\/?$/, (data, match, end, query) => { + const { req: request, res: response } = query; + const { user } = request; + log('blog-new'); + routes.blogPageNew(response, user, blog); + }); - // New public blog page. - app.route(/^\/blog-new\/?$/, (data, match, end, query) => { - const { req: request, res: response } = query; - const { user } = request; - log('blog-new'); - routes.blogPageNew(response, user, blog); - }); + // Public live data page. + app.route(/^\/data\/?$/, (data, match, end, query) => { + const { user } = query.req; + routes.dataPage(query.res, user); + }); - // Public live data page. - app.route(/^\/data\/?$/, (data, match, end, query) => { - const { user } = query.req; - routes.dataPage(query.res, user); - }); + // Public live data page. + app.route(/^\/data-new\/?$/, (data, match, end, query) => { + const { user } = query.req; + routes.dataPageNew(query.res, user); + }); - // Public live data page. - app.route(/^\/data-new\/?$/, (data, match, end, query) => { - const { user } = query.req; - routes.dataPageNew(query.res, user); - }); + // Public design page + app.route(/^\/design\/?$/, (data, match, end, query) => { + const { user } = query.req; + routes.designPage(query.res, user); + }); - // Public design page - app.route(/^\/design\/?$/, (data, match, end, query) => { - const { user } = query.req; - routes.designPage(query.res, user); - }); + // new login page + app.route(/^\/login-new\/?$/, (data, match, end, query) => { + const { user } = query.req; + routes.newLoginPage(query.res, user); + }); - // new login page - app.route(/^\/login-new\/?$/, (data, match, end, query) => { - const { user } = query.req; - routes.newLoginPage(query.res, user); - }); + // Public project pages. + app.route(/^\/projects(\/[\w-]+)?\/?$/, (data, match, end, query) => { + const { user } = query.req; + const projectUri = match[1]; + if (!projectUri) { + // No particular project was requested, show them all. + routes.projectsPage(query.res, user); + return; + } - // Public project pages. - app.route(/^\/projects(\/[\w-]+)?\/?$/, (data, match, end, query) => { - const { user } = query.req; - const projectUri = match[1]; - if (!projectUri) { - // No particular project was requested, show them all. - routes.projectsPage(query.res, user); - return; - } + const projectId = projectUri.slice(1); + const project = db.get('projects')[projectId]; + if (!project) { + // The requested project doesn't exist. + routes.notFoundPage(query.res, user); + return; + } - const projectId = projectUri.slice(1); - const project = db.get('projects')[projectId]; - if (!project) { - // The requested project doesn't exist. - routes.notFoundPage(query.res, user); - return; - } + routes.projectPage(query.res, project, user); + }); - routes.projectPage(query.res, project, user); - }); + // New public project pages. + app.route(/^\/projects-new(\/[\w-]+)?\/?$/, (data, match, end, query) => { + const { user } = query.req; + const projectUri = match[1]; + if (!projectUri) { + // No particular project was requested, show them all. + routes.projectsPageNew(query.res, user); + return; + } - // New public project pages. - app.route(/^\/projects-new(\/[\w-]+)?\/?$/, (data, match, end, query) => { - const { user } = query.req; - const projectUri = match[1]; - if (!projectUri) { - // No particular project was requested, show them all. - routes.projectsPageNew(query.res, user); - return; - } + const projectId = projectUri.slice(1); + const project = db.get('projects')[projectId]; + if (!project) { + // The requested project doesn't exist. + routes.notFoundPageNew(query.res, user); + return; + } - const projectId = projectUri.slice(1); - const project = db.get('projects')[projectId]; - if (!project) { - // The requested project doesn't exist. - routes.notFoundPageNew(query.res, user); - return; - } + routes.projectPageNew(query.res, project, user); + }); - routes.projectPageNew(query.res, project, user); - }); + // User logout. + app.route(/^\/logout\/?$/, (data, match, end, query) => { + users.logout(query.req, error => { + if (error) { + log('[fail] logout', error); + } - // User logout. - app.route(/^\/logout\/?$/, (data, match, end, query) => { - users.logout(query.req, error => { - if (error) { - log('[fail] logout', error); + routes.redirect(query.res, '/'); + }); + }); + + // User login page. + app.route(/^\/login\/?$/, (data, match, end, query) => { + const { user } = query.req; + if (!user) { + routes.loginPage(query.res); + return; } routes.redirect(query.res, '/'); }); - }); - // User login page. - app.route(/^\/login\/?$/, (data, match, end, query) => { - const { user } = query.req; - if (!user) { - routes.loginPage(query.res); - return; - } - - routes.redirect(query.res, '/'); - }); - - // User login via GitHub. - app.route(/^\/login\/github\/?$/, async (data, match, end, query) => { - const { req: request, res: response } = query; - const { user } = request; - if (!user) { - // Don't allow signing in only with GitHub just yet. - routes.notFoundPage(response, user); - return; - } + // User login via GitHub. + app.route(/^\/login\/github\/?$/, async (data, match, end, query) => { + const { req: request, res: response } = query; + const { user } = request; + if (!user) { + // Don't allow signing in only with GitHub just yet. + routes.notFoundPage(response, user); + return; + } - let accessToken = null; - let refreshToken = null; - try { - ({ accessToken, refreshToken } = await github.authenticate(request)); - } catch (error) { - log('[fail] github authentication', error); - routes.notFoundPage(response, user); - return; - } + let accessToken = null; + let refreshToken = null; + try { + ({ accessToken, refreshToken } = await github.authenticate(request)); + } catch (error) { + log('[fail] github authentication', error); + routes.notFoundPage(response, user); + return; + } - try { - await users.refreshGitHubAccount(user, accessToken, refreshToken); - } catch (error) { - log('[fail] could not refresh github account', error); - } + try { + await users.refreshGitHubAccount(user, accessToken, refreshToken); + } catch (error) { + log('[fail] could not refresh github account', error); + } - routes.redirect(response, '/settings/integrations/'); - }); + routes.redirect(response, '/settings/integrations/'); + }); - // User OAuth2 authorization. - app.route(/^\/login\/oauth\/authorize\/?$/, (data, match, end, query) => { - const { req: request, res: response } = query; - const { user } = request; - if (!user) { - routes.notFoundPage(response, user); - return; - } + // User OAuth2 authorization. + app.route(/^\/login\/oauth\/authorize\/?$/, (data, match, end, query) => { + const { req: request, res: response } = query; + const { user } = request; + if (!user) { + routes.notFoundPage(response, user); + return; + } - hosts.issueOAuth2AuthorizationCode(request).then(data => { - routes.redirect(response, data.redirect_url); - }).catch(error => { - log('[fail] oauth2 authorize', error); - // Note: Such OAuth2 sanity problems should rarely happen, but if they - // do become more frequent, we should inform the user about what's - // happening here instead of showing a generic 404 page. - routes.notFoundPage(response, user); + hosts.issueOAuth2AuthorizationCode(request).then(data => { + routes.redirect(response, data.redirect_url); + }).catch(error => { + log('[fail] oauth2 authorize', error); + // Note: Such OAuth2 sanity problems should rarely happen, but if they + // do become more frequent, we should inform the user about what's + // happening here instead of showing a generic 404 page. + routes.notFoundPage(response, user); + }); }); - }); - // OAuth2 access token request. - app.route(/^\/login\/oauth\/access_token\/?$/, (data, match, end, query) => { - const { req: request, res: response } = query; - if (request.method !== 'POST') { - routes.notFoundPage(response, request.user); - return; - } + // OAuth2 access token request. + app.route(/^\/login\/oauth\/access_token\/?$/, (data, match, end, query) => { + const { req: request, res: response } = query; + if (request.method !== 'POST') { + routes.notFoundPage(response, request.user); + return; + } - const authenticatedHostname = hosts.authenticate(request); - if (!authenticatedHostname) { - response.statusCode = 403; // Forbidden - response.json({ error: 'Unauthorized' }); - return; - } + const authenticatedHostname = hosts.authenticate(request); + if (!authenticatedHostname) { + response.statusCode = 403; // Forbidden + response.json({ error: 'Unauthorized' }); + return; + } - hosts.issueOAuth2AccessToken(request).then(data => { - response.json(data, null, 2); - }).catch(error => { - log('[fail] oauth2 token', error); - response.statusCode = 400; // Bad Request - response.json({ error: 'Could not issue OAuth2 access token' }, null, 2); + hosts.issueOAuth2AccessToken(request).then(data => { + response.json(data, null, 2); + }).catch(error => { + log('[fail] oauth2 token', error); + response.statusCode = 400; // Bad Request + response.json({ error: 'Could not issue OAuth2 access token' }, null, 2); + }); }); - }); - - // User contributions list. (legacy - redirect to containers page) - app.route(/^\/contributions\/?$/, (data, match, end, query) => { - routes.redirect(query.res, '/containers/', true); - }); - // User containers list. - app.route(/^\/containers\/?$/, (data, match, end, query) => { - const { req: request, res: response } = query; - const { user } = request; - if (!user) { - routes.loginPage(response); - return; - } + // User contributions list. (legacy - redirect to containers page) + app.route(/^\/contributions\/?$/, (data, match, end, query) => { + routes.redirect(query.res, '/containers/', true); + }); - routes.containersPage(response, user); - }); + // User containers list. + app.route(/^\/containers\/?$/, (data, match, end, query) => { + const { req: request, res: response } = query; + const { user } = request; + if (!user) { + routes.loginPage(response); + return; + } - // User new containers list. - app.route(/^\/containers-new\/?$/, (data, match, end, query) => { - const { req: request, res: response } = query; - const { user } = request; - if (!user) { - routes.loginPage(response); - return; - } + routes.containersPage(response, user); + }); - routes.containersPageNew(response, user); - }); + // User new containers list. + app.route(/^\/containers-new\/?$/, (data, match, end, query) => { + const { req: request, res: response } = query; + const { user } = request; + if (!user) { + routes.loginPage(response); + return; + } - // User notifications. - app.route(/^\/notifications\/?$/, (data, match, end, query) => { - const { user } = query.req; - if (!user) { - routes.loginPage(query.res); - return; - } + routes.containersPageNew(response, user); + }); - routes.notificationsPage(query.res, user); - }); + // User notifications. + app.route(/^\/notifications\/?$/, (data, match, end, query) => { + const { user } = query.req; + if (!user) { + routes.loginPage(query.res); + return; + } - // User settings. - app.route(/^\/settings(\/\w+)?\/?$/, (data, match, end, query) => { - const { req: request, res: response } = query; - const { user } = request; - if (!user) { - routes.loginPage(response); - return; - } + routes.notificationsPage(query.res, user); + }); - // Select the requested section, or serve the default one. - const sectionUri = match[1]; - const section = sectionUri ? sectionUri.slice(1) : 'account'; + // User settings. + app.route(/^\/settings(\/\w+)?\/?$/, (data, match, end, query) => { + const { req: request, res: response } = query; + const { user } = request; + if (!user) { + routes.loginPage(response); + return; + } - routes.settingsPage(request, response, section, user); - }); + // Select the requested section, or serve the default one. + const sectionUri = match[1]; + const section = sectionUri ? sectionUri.slice(1) : 'account'; - // New settings page. - app.route(/^\/settings-new\/?$/, (data, match, end, query) => { - const { req: request, res: response } = query; - const { user } = request; - if (!user) { - routes.loginPage(response); - return; - } + routes.settingsPage(request, response, section, user); + }); - routes.settingsPageNew(request, response, user); - }); + // New settings page. + app.route(/^\/settings-new\/?$/, (data, match, end, query) => { + const { req: request, res: response } = query; + const { user } = request; + if (!user) { + routes.loginPage(response); + return; + } - // User account (now part of settings). - app.route(/^\/account\/?$/, (data, match, end, query) => { - routes.redirect(query.res, '/settings/account/', true); - }); + routes.settingsPageNew(request, response, user); + }); - app.route(/^\/[.,;)]$/, (data, match, end, query) => { - routes.redirect(query.res, '/', true); - }); + // User account (now part of settings). + app.route(/^\/account\/?$/, (data, match, end, query) => { + routes.redirect(query.res, '/settings/account/', true); + }); - // Admin sections. - app.route(/^\/admin(\/\w+)?\/?$/, (data, match, end, query) => { - const { user } = query.req; - if (!users.isAdmin(user)) { - routes.notFoundPage(query.res, user); - return; - } + app.route(/^\/[.,;)]$/, (data, match, end, query) => { + routes.redirect(query.res, '/', true); + }); - // Select the requested section, or serve the default one. - const sectionUri = match[1]; - const section = sectionUri ? sectionUri.slice(1) : 'docker'; + // Admin sections. + app.route(/^\/admin(\/\w+)?\/?$/, (data, match, end, query) => { + const { user } = query.req; + if (!users.isAdmin(user)) { + routes.notFoundPage(query.res, user); + return; + } - log('admin', section, '(' + user._primaryEmail + ')'); + // Select the requested section, or serve the default one. + const sectionUri = match[1]; + const section = sectionUri ? sectionUri.slice(1) : 'docker'; - routes.adminPage(query.res, section, user); - }); + log('admin', section, '(' + user._primaryEmail + ')'); - // New 404 Not Found page - app.route(/^\/404-new\/?$/, (data, match, end, query) => { - const { user } = query.req; - log('404-new', match[0]); - routes.notFoundPageNew(query.res, user); - }); - - // 404 Not Found. - app.notfound(/.*/, (data, match, end, query) => { - const { user } = query.req; - log('404', match[0]); - routes.notFoundPage(query.res, user); - }); + routes.adminPage(query.res, section, user); + }); - // Alpha version sign-up. - app.ajax.on('signup', (data, end) => { - const email = data.email; - const users = db.get('users'); - const waitlist = db.get('waitlist'); + // New 404 Not Found page + app.route(/^\/404-new\/?$/, (data, match, end, query) => { + const { user } = query.req; + log('404-new', match[0]); + routes.notFoundPageNew(query.res, user); + }); - log('signup', email); + // 404 Not Found. + app.notfound(/.*/, (data, match, end, query) => { + const { user } = query.req; + log('404', match[0]); + routes.notFoundPage(query.res, user); + }); - if (waitlist[email]) { - end({ status: 'already-added' }); - return; - } + // Alpha version sign-up. + app.ajax.on('signup', (data, end) => { + const email = data.email; + const users = db.get('users'); + const waitlist = db.get('waitlist'); - if (users[email]) { - end({ status: 'already-invited' }); - return; - } + log('signup', email); - waitlist[email] = Date.now(); - db.save(); + if (waitlist[email]) { + end({ status: 'already-added' }); + return; + } - end({ status: 'added' }); - }); + if (users[email]) { + end({ status: 'already-invited' }); + return; + } - // Alpha version invite. - app.ajax.on('invite', (data, end, query) => { - const { user } = query.req; - if (!users.isAdmin(user)) { - end(); - return; - } + waitlist[email] = Date.now(); + db.save(); - const email = data.email; - if (email in db.get('users')) { - end({ status: 'already-invited' }); - return; - } + end({ status: 'added' }); + }); - users.sendInviteEmail(email, error => { - if (error) { - const message = String(error); - log(message, '(while inviting ' + email + ')'); - end({ status: 'error', message: message }); + // Alpha version invite. + app.ajax.on('invite', (data, end, query) => { + const { user } = query.req; + if (!users.isAdmin(user)) { + end(); return; } - end({ status: 'invited' }); - }); - }); - // Request a log-in key via email. - app.ajax.on('login', (data, end, query) => { - const { user } = query.req; - if (user) { - end({ status: 'logged-in' }); - return; - } - - const email = data.email; - users.sendLoginEmail(email, query.req, error => { - if (error) { - const message = String(error); - log(message, '(while emailing ' + email + ')'); - end({ status: 'error', message: message }); + const email = data.email; + if (email in db.get('users')) { + end({ status: 'already-invited' }); return; } - end({ status: 'email-sent' }); - }); - }); - // Change the parameters of a project. - app.ajax.on('projectdb', (data, end, query) => { - const { user } = query.req; - if (!users.isAdmin(user)) { - end(); - return; - } + users.sendInviteEmail(email, error => { + if (error) { + const message = String(error); + log(message, '(while inviting ' + email + ')'); + end({ status: 'error', message: message }); + return; + } + end({ status: 'invited' }); + }); + }); - if (!data.id) { - end({ status: 'error', message: 'Invalid project ID' }); - return; - } + // Request a log-in key via email. + app.ajax.on('login', (data, end, query) => { + const { user } = query.req; + if (user) { + end({ status: 'logged-in' }); + return; + } - machines.setProject(data); - end({ status: 'success' }); - }); + const email = data.email; + users.sendLoginEmail(email, query.req, error => { + if (error) { + const message = String(error); + log(message, '(while emailing ' + email + ')'); + end({ status: 'error', message: message }); + return; + } + end({ status: 'email-sent' }); + }); + }); - // Update the base image of a project. - app.ajax.on('update', (data, end, query) => { - const { user } = query.req; - if (!users.isAdmin(user)) { - end(); - return; - } + // Change the parameters of a project. + app.ajax.on('projectdb', (data, end, query) => { + const { user } = query.req; + if (!users.isAdmin(user)) { + end(); + return; + } - machines.update(data.project, error => { - if (error) { - end({ status: 'error', message: String(error) }); + if (!data.id) { + end({ status: 'error', message: 'Invalid project ID' }); return; } + + machines.setProject(data); end({ status: 'success' }); }); - // For longer requests, make sure we reply before the browser retries. - setTimeout(() => { - end({ status: 'started' }); - }, 42000); + // Update the base image of a project. + app.ajax.on('update', (data, end, query) => { + const { user } = query.req; + if (!users.isAdmin(user)) { + end(); + return; + } + + machines.update(data.project, error => { + if (error) { + end({ status: 'error', message: String(error) }); + return; + } + end({ status: 'success' }); + }); + + // For longer requests, make sure we reply before the browser retries. + setTimeout(() => { + end({ status: 'started' }); + }, 42000); + }); }); -}); diff --git a/join.js b/join.js index bcbf8f89..15b28470 100644 --- a/join.js +++ b/join.js @@ -24,13 +24,15 @@ if (!hostname || hostname === 'localhost') { log('[ok] will try to join cluster as [hostname = ' + hostname + ']'); -boot.executeInParallel([ - boot.forwardHttp, - boot.ensureHttpsCertificates, - boot.ensureDockerTlsCertificates, - boot.verifyJanitorOAuth2Access -], () => { - boot.registerDockerClient(() => { +Promise.all([ + boot.forwardHttp(), + boot.ensureHttpsCertificates(), + boot.ensureDockerTlsCertificates(), + boot.verifyJanitorOAuth2Access() +]) + .then(() => boot.registerDockerClient()) + .catch(err => log('[fail] could not join cluster', err)) + .then(() => { log('[ok] joined cluster as [hostname = ' + hostname + ']'); const https = db.get('https'); @@ -80,7 +82,6 @@ boot.executeInParallel([ }); }); }); -}); // Associate some non-persistent data to sessions. const oauth2States = {}; diff --git a/lib/__mocks__/db.js b/lib/__mocks__/db.js new file mode 100644 index 00000000..b0af6065 --- /dev/null +++ b/lib/__mocks__/db.js @@ -0,0 +1,15 @@ +let store = {}; + +exports.get = function (key, defaultValue) { + if (!store[key]) { + store[key] = defaultValue || {}; + } + + return store[key]; +}; + +exports.save = () => {}; + +exports.__setData = newStore => { + store = Object.assign({}, newStore); +}; diff --git a/lib/blog.js b/lib/blog.js index 0a440c7c..e5e0202f 100644 --- a/lib/blog.js +++ b/lib/blog.js @@ -94,7 +94,7 @@ exports.synchronize = async function () { blog.topics = await Promise.all(topicsPromises); const now = Date.now(); metrics.set(blog, 'updated', now); - metrics.push(blog, 'pull-time', [now, now - time]); + metrics.push(blog, 'pull-time', [ now, now - time ]); db.save(); return { count: topics.length }; }; diff --git a/lib/boot.js b/lib/boot.js index 4380f7aa..6ef93287 100644 --- a/lib/boot.js +++ b/lib/boot.js @@ -3,6 +3,10 @@ const fs = require('fs'); const http = require('http'); +const { promisify } = require('util'); +const chmod = promisify(fs.chmod); +const readFile = promisify(fs.readFile); +const writeFile = promisify(fs.writeFile); const certificates = require('./certificates'); const db = require('./db'); @@ -11,26 +15,8 @@ const oauth2 = require('./oauth2'); const hostname = db.get('hostname', 'localhost'); -// Run given tasks in parrallel, and only call `next()` when all have succeeded. -exports.executeInParallel = function (tasks, next) { - let complete = 0; - - for (const task of tasks) { - try { - task(() => { - complete++; - if (complete === tasks.length) { - next(); - } - }); - } catch (error) { - log('[fail] boot task', error); - } - } -}; - // Permanently redirect all HTTP requests to HTTPS. -exports.forwardHttp = function (next) { +exports.forwardHttp = function () { const ports = db.get('ports'); if (!ports.http || !ports.https) { // Use `make ports` to set up this unprivileged HTTP port. @@ -55,17 +41,16 @@ exports.forwardHttp = function (next) { response.end(); }); - forwarder.listen(ports.http, () => { + const listen = promisify(forwarder.listen); + return listen.call(forwarder, ports.http).then(() => { log('[ok] forwarding http:// → https://'); - next(); }); }; // Verify HTTPS certificates and generate new ones if necessary. -exports.ensureHttpsCertificates = function (next) { +exports.ensureHttpsCertificates = async function () { if (db.get('security').forceHttp) { log('[warning] skipped https credentials verification'); - next(); return; } @@ -79,7 +64,6 @@ exports.ensureHttpsCertificates = function (next) { if (valid) { log('[ok] verified https credentials'); - next(); return; } @@ -100,7 +84,7 @@ exports.ensureHttpsCertificates = function (next) { } log('requesting new https credentials…'); - certificates.createHTTPSCertificate(parameters) + return certificates.createHTTPSCertificate(parameters) .then(({ certificate, accountKey }) => { https.ca = [ certificate.ca ]; https.crt = certificate.cert; @@ -108,15 +92,11 @@ exports.ensureHttpsCertificates = function (next) { letsencrypt.key = accountKey; db.save(); log('[ok] new https credentials installed'); - next(); - }) - .catch(error => { - log('[fail] letsencrypt', error); }); }; // Verify Docker TLS certificates, generate new ones if necessary. -exports.ensureDockerTlsCertificates = function (next) { +exports.ensureDockerTlsCertificates = async function () { // Read all TLS certificates. const tls = db.get('tls'); const ca = tls.ca || {}; @@ -124,13 +104,13 @@ exports.ensureDockerTlsCertificates = function (next) { const server = {}; try { - server.ca = fs.readFileSync('./docker.ca', 'utf8'); - server.crt = fs.readFileSync('./docker.crt', 'utf8'); - server.key = fs.readFileSync('./docker.key', 'utf8'); + server.ca = await readFile('./docker.ca', 'utf8'); + server.crt = await readFile('./docker.crt', 'utf8'); + server.key = await readFile('./docker.key', 'utf8'); } catch (error) { if (error.code !== 'ENOENT') { log('[fail] could not read docker-tls certificates', error); - return; + throw error; } } @@ -142,7 +122,7 @@ exports.ensureDockerTlsCertificates = function (next) { let serverValid = false; if (!caValid) { - resetAllCertificates(); + await resetAllCertificates(); return; } @@ -161,20 +141,19 @@ exports.ensureDockerTlsCertificates = function (next) { if (caValid && clientValid && serverValid) { log('[ok] verified docker-tls credentials'); - next(); return; } if (!clientValid) { - resetClientCertificate(); + await resetClientCertificate(); } if (!serverValid) { - resetServerCertificate(); + await resetServerCertificate(); } // Task: Reset the TLS certificate authority, then all depending certificates. - function resetAllCertificates () { + async function resetAllCertificates () { const parameters = { commonName: 'ca', basicConstraints: { cA: true }, @@ -186,16 +165,13 @@ exports.ensureDockerTlsCertificates = function (next) { }; log('generating new docker-tls certificate authority…'); - certificates.createTLSCertificate(parameters).then(({ crt, key }) => { - ca.crt = crt; - ca.key = key; - tls.ca = ca; - caValid = true; - resetClientCertificate(); - resetServerCertificate(); - }).catch(error => { - log('[fail] tls', error); - }); + const { crt, key } = await certificates.createTLSCertificate(parameters); + ca.crt = crt; + ca.key = key; + tls.ca = ca; + caValid = true; + await resetClientCertificate(); + await resetServerCertificate(); } // Task: Reset the TLS client certificate. @@ -210,19 +186,16 @@ exports.ensureDockerTlsCertificates = function (next) { }; log('generating new docker-tls client certificate…'); - certificates.createTLSCertificate(parameters).then(({ crt, key }) => { + return certificates.createTLSCertificate(parameters).then(({ crt, key }) => { client.crt = crt; client.key = key; tls.client = client; clientValid = true; - done(); - }).catch(error => { - log('[fail] tls', error); }); } // Task: Reset the TLS server certificate. - function resetServerCertificate () { + async function resetServerCertificate () { const parameters = { commonName: hostname, altNames: [ 'localhost' ], @@ -231,81 +204,61 @@ exports.ensureDockerTlsCertificates = function (next) { }; log('generating new docker-tls server certificate…'); - certificates.createTLSCertificate(parameters).then(({ crt, key }) => { - server.crt = crt; - server.key = key; - const filesToWrite = { - './docker.ca': ca.crt, - './docker.crt': server.crt, - './docker.key': server.key - }; - - for (const file in filesToWrite) { - const path = file; - const value = filesToWrite[path]; - fs.writeFile(path, value, (error) => { - if (error) { - log('[fail] unable to write ' + path, error); - return; - } - - fs.chmod(path, 0o600 /* read + write by owner */, (error) => { - if (error) { - log('[fail] unable to protect ' + path, error); - return; - } - - delete filesToWrite[path]; - if (Object.keys(filesToWrite).length === 0) { - // FIXME: Can we force the docker daemon to restart here, or to - // switch certificates? Maybe we can do something like this: - // `exec('sudo service docker restart')` ? - log('[fail] please manually restart the docker daemon'); - // But continue anyway. - serverValid = true; - done(); - } - }); - }); + const { crt, key } = await certificates.createTLSCertificate(parameters); + server.crt = crt; + server.key = key; + const filesToWrite = { + './docker.ca': ca.crt, + './docker.crt': server.crt, + './docker.key': server.key + }; + + for (const file in filesToWrite) { + const path = file; + const value = filesToWrite[path]; + try { + await writeFile(path, value); + } catch (error) { + log('[fail] unable to write ' + path, error); + throw error; + } + try { + await chmod(path, 0o600 /* read + write by owner */); + } catch (error) { + log('[fail] unable to protect ' + path, error); + throw error; } - }).catch(error => { - log('[fail] tls', error); - }); - } - // Wait for all required tasks to finish before proceeding. - function done () { - if (!caValid || !clientValid || !serverValid) { - // Some tasks are not finished yet. Let's wait. - return; + delete filesToWrite[path]; + if (Object.keys(filesToWrite).length === 0) { + // FIXME: Can we force the docker daemon to restart here, or to + // switch certificates? Maybe we can do something like this: + // `exec('sudo service docker restart')` ? + log('[warning] please manually restart the docker daemon'); + // But continue anyway. + serverValid = true; + } } - - // eslint-disable-next-line no-func-assign - done = null; - db.save(); - log('[ok] new docker-tls credentials installed'); - next(); } + + db.save(); + log('[ok] new docker-tls credentials installed'); }; // Verify OAuth2 client access to a Janitor instance (for cluster hosts). -exports.verifyJanitorOAuth2Access = function (next) { +exports.verifyJanitorOAuth2Access = async function () { const parameters = { provider: 'janitor', path: '/api/hosts/' + hostname, serviceRequest: true }; - oauth2.request(parameters).then(({ body, response }) => { - log('[ok] verified janitor-oauth2 access'); - next(); - }).catch(error => { - log('[fail] janitor-oauth2 access problem', error); - }); + await oauth2.request(parameters); + log('[ok] verified janitor-oauth2 access'); }; // Provide our Docker TLS client certificates to the Janitor instance. -exports.registerDockerClient = function (next) { +exports.registerDockerClient = async function () { const { ca, client } = db.get('tls'); const parameters = { provider: 'janitor', @@ -320,10 +273,6 @@ exports.registerDockerClient = function (next) { serviceRequest: true }; - oauth2.request(parameters).then(({ body, response }) => { - log('[ok] registered docker-tls credentials'); - next(); - }).catch(error => { - log('[fail] unable to register docker-tls credentials:', error); - }); + await oauth2.request(parameters); + log('[ok] registered docker-tls credentials'); }; diff --git a/lib/certificates.js b/lib/certificates.js index 9b49e22c..b972bbcc 100644 --- a/lib/certificates.js +++ b/lib/certificates.js @@ -177,7 +177,7 @@ exports.createTLSCertificate = async function (parameters) { const extensions = [{ name: 'subjectAltName', - altNames: altNames.map((altName) => ({ + altNames: altNames.map(altName => ({ type: 2, // DNS value: altName })) diff --git a/lib/docker.js b/lib/docker.js index b0b505f9..4fb99f47 100644 --- a/lib/docker.js +++ b/lib/docker.js @@ -9,18 +9,16 @@ const util = require('util'); const db = require('./db'); const hosts = require('./hosts'); -const log = require('./log'); // Get client access to a given Docker host. -function getDocker (hostname, callback) { +function getDocker (hostname) { const host = hosts.get(hostname); if (!host) { - callback(new Error('Unknown Docker host: ' + hostname)); - return; + throw new Error('Unknown Docker host: ' + hostname); } const { ca, client } = db.get('tls'); - const docker = new Dockerode({ + return new Dockerode({ protocol: 'https', host: hostname, port: Number(host.properties.port), @@ -28,302 +26,196 @@ function getDocker (hostname, callback) { cert: host.properties.cert || host.properties.crt || client.crt, key: host.properties.key || client.key }); - - callback(null, docker); } // List all Docker images on a given host. -exports.listImages = function (parameters, callback) { - const { host } = parameters; - - getDocker(host, (error, docker) => { - if (error) { - callback(error); - return; - } - - docker.listImages({ all: 1 }, (error, images) => { - callback(error, images); - }); - }); +exports.listImages = async function ({ host }) { + const docker = getDocker(host); + return docker.listImages({ all: 1 }); }; // Build a Docker image from a given Dockerfile. -exports.buildImage = function (parameters, callback) { +exports.buildImage = async function (parameters) { const { host, tag, dockerfile } = parameters; - getDocker(host, (error, docker) => { - if (error) { - callback(error); - return; - } + const docker = getDocker(host); + + // Add the Dockerfile to a tar stream for Docker's Remote API. + const pack = tar.pack(); + pack.entry({ name: 'Dockerfile' }, dockerfile); + pack.finalize(); - // Add the Dockerfile to a tar stream for Docker's Remote API. - const pack = tar.pack(); - pack.entry({ name: 'Dockerfile' }, dockerfile); - pack.finalize(); - - // FIXME: If `docker.buildImage()` ever supports streams, use the tar stream - // directly instead of flushing it into a Buffer. - const chunks = []; - pack.on('data', chunk => { chunks.push(chunk); }); - pack.on('end', () => { - const buffer = Buffer.concat(chunks); - const options = { - t: tag, - nocache: true - }; - - docker.buildImage(buffer, options, (error, response) => { - if (error) { - callback(error); - return; - } + // FIXME: If `docker.buildImage()` ever supports streams, use the tar stream + // directly instead of flushing it into a Buffer. + const chunks = []; + pack.on('data', chunk => { chunks.push(chunk); }); + return new Promise((resolve, reject) => { + pack.on('end', async () => { + try { + const buffer = Buffer.concat(chunks); + const options = { + t: tag, + nocache: true + }; + + const response = await docker.buildImage(buffer, options); // Transform Docker's response into a proper Node.js Stream. const dockerResponse = new DockerResponse(); response.pipe(dockerResponse); - callback(null, dockerResponse); - }); + resolve(dockerResponse); + } catch (error) { + reject(error); + } }); }); }; // Pull a Docker image into a given host. -exports.pullImage = function (parameters, callback) { +exports.pullImage = async function (parameters) { const { host, image: imageId } = parameters; - getDocker(host, (error, docker) => { - if (error) { - callback(error); - return; - } - - docker.pull(imageId, function (error, stream) { - if (error) { - callback(error); - return; - } - - callback(null, stream); - }); - }); + const docker = getDocker(host); + return docker.pull(imageId); }; // Get low-level information on a Docker image from a given host. -exports.inspectImage = function (parameters, callback) { +exports.inspectImage = async function (parameters) { const { host, image: imageId } = parameters; - getDocker(host, (error, docker) => { - if (error) { - callback(error); - return; - } - - const image = docker.getImage(imageId); - image.inspect((error, data) => { - callback(error, data); - }); - }); + const docker = getDocker(host); + const image = docker.getImage(imageId); + return image.inspect(); }; // Tag a Docker image in a given host. -exports.tagImage = function (parameters, callback) { +exports.tagImage = async function (parameters) { const { host, image: imageId, tag: tagId } = parameters; - getDocker(host, (error, docker) => { - if (error) { - callback(error); - return; - } + const docker = getDocker(host); - const image = docker.getImage(imageId); - const [ repo, tag = 'latest' ] = tagId.split(':'); - image.tag({ repo, tag }, (error, data) => { - callback(error); - }); - }); + const image = docker.getImage(imageId); + const [ repo, tag = 'latest' ] = tagId.split(':'); + return image.tag({ repo, tag }); }; // Delete a Docker image from a given host. -exports.removeImage = function (parameters, callback) { +exports.removeImage = async function (parameters) { const { host, image: imageId } = parameters; - getDocker(host, (error, docker) => { - if (error) { - callback(error); - return; - } + const docker = getDocker(host); - const image = docker.getImage(imageId); - image.remove((error, data) => { - callback(error); - }); - }); + const image = docker.getImage(imageId); + return image.remove(); }; // List all Docker containers on a given host. -exports.listContainers = function (parameters, callback) { +exports.listContainers = function (parameters) { const { host } = parameters; - getDocker(host, (error, docker) => { - if (error) { - callback(error); - return; - } + const docker = getDocker(host); - docker.listContainers({ all: 1 }, (error, containers) => { - callback(error, containers); - }); - }); + return docker.listContainers({ all: 1 }); }; // Spawn a new Docker container from a given image. -exports.runContainer = function (parameters, callback) { +exports.runContainer = async function (parameters) { const { host, image, ports } = parameters; - getDocker(host, (error, docker) => { - if (error) { - callback(error); - return; - } + const docker = getDocker(host); - const options = { - Image: image, - ExposedPorts: {}, - HostConfig: { PortBindings: {} } - }; - - for (const port in ports) { - options.ExposedPorts[port + '/tcp'] = {}; - options.HostConfig.PortBindings[port + '/tcp'] = [{ - HostIp: ports[port].publish ? '0.0.0.0' : '127.0.0.1', - HostPort: String(ports[port].hostPort) - }]; - } + const options = { + Image: image, + ExposedPorts: {}, + HostConfig: { PortBindings: {} } + }; - docker.createContainer(options, (error, container) => { - if (error) { - callback(error, container); - return; - } + for (const port in ports) { + options.ExposedPorts[port + '/tcp'] = {}; + options.HostConfig.PortBindings[port + '/tcp'] = [{ + HostIp: ports[port].publish ? '0.0.0.0' : '127.0.0.1', + HostPort: String(ports[port].hostPort) + }]; + } - container.start((error, logs) => { - callback(error, container, logs); - }); - }); - }); + const container = await docker.createContainer(options); + return { container, logs: await container.start() }; }; // Copy files into a given Docker container. -exports.copyIntoContainer = function (parameters, callback) { +exports.copyIntoContainer = async function (parameters) { const { host, container: containerId, files, path } = parameters; - getDocker(host, (error, docker) => { - if (error) { - callback(error); - return; - } + const docker = getDocker(host); - // Add the files to a tar stream for Docker's Remote API. - const pack = tar.pack(); - for (const name in files) { - pack.entry({ name }, files[name]); - } - pack.finalize(); - - // FIXME: If `container.putArchive()` ever supports streams, use the tar - // stream directly instead of flushing it into a Buffer. - const chunks = []; - pack.on('data', chunk => { chunks.push(chunk); }); - pack.on('end', () => { - const buffer = Buffer.concat(chunks); - const container = docker.getContainer(containerId); - - container.putArchive(buffer, { path }, (error, response) => { - callback(error); - }); + // Add the files to a tar stream for Docker's Remote API. + const pack = tar.pack(); + for (const name in files) { + pack.entry({ name }, files[name]); + } + pack.finalize(); + + // FIXME: If `container.putArchive()` ever supports streams, use the tar + // stream directly instead of flushing it into a Buffer. + const chunks = []; + pack.on('data', chunk => { chunks.push(chunk); }); + return new Promise((resolve, reject) => { + pack.on('end', async () => { + try { + const buffer = Buffer.concat(chunks); + const container = docker.getContainer(containerId); + + resolve(await container.putArchive(buffer, { path })); + } catch (error) { + reject(error); + } }); }); }; // Execute a specific command inside a given Docker container. -exports.execInContainer = function (parameters, callback) { +exports.execInContainer = async function (parameters) { const { host, container: containerId, command } = parameters; - getDocker(host, (error, docker) => { - if (error) { - callback(error); - return; - } + const docker = getDocker(host); + const container = docker.getContainer(containerId); + const options = { + Cmd: [ '/bin/bash', '-c', command ], + AttachStdout: true, + AttachStderr: true + }; - const container = docker.getContainer(containerId); - const options = { - Cmd: [ '/bin/bash', '-c', command ], - AttachStdout: true, - AttachStderr: true - }; - - container.exec(options, (error, exec) => { - if (error) { - callback(error); - return; - } - - exec.start((error, stream) => { - callback(error, stream); - }); - }); - }); + const exec = await container.exec(options); + return exec.start(); }; // List all files that were modified, added or deleted in a Docker container. -exports.listChangedFilesInContainer = function (parameters, callback) { +exports.listChangedFilesInContainer = async function (parameters) { const { host, container: containerId } = parameters; - getDocker(host, (error, docker) => { - if (error) { - callback(error); - return; - } + const docker = getDocker(host); - const container = docker.getContainer(containerId); - container.changes((error, changedFiles) => { - callback(error, changedFiles); - }); - }); + const container = docker.getContainer(containerId); + return container.changes(); }; // Kill and delete a Docker container from a given host. -exports.removeContainer = function (parameters, callback) { +exports.removeContainer = async function (parameters) { const { host, container: containerId } = parameters; - getDocker(host, (error, docker) => { - if (error) { - callback(error); - return; - } + const docker = getDocker(host); - const container = docker.getContainer(containerId); - container.remove({ force: true }, (error, data) => { - callback(error); - }); - }); + const container = docker.getContainer(containerId); + return container.remove({ force: true }); }; // Get the Docker version of a given host. -exports.version = function (parameters, callback) { +exports.version = async function (parameters) { const { host } = parameters; - getDocker(host, (error, docker) => { - if (error) { - log('[fail] could not get the docker client', error); - } - docker.version((error, data) => { - callback(error, data); - }); - }); + const docker = getDocker(host); + return docker.version(); }; // Docker Remote API response stream. diff --git a/lib/hosts.js b/lib/hosts.js index 5c87bb1e..189df6f3 100644 --- a/lib/hosts.js +++ b/lib/hosts.js @@ -72,7 +72,7 @@ exports.create = function (hostname, properties, callback) { } }; - exports.resetOAuth2ClientSecret(host, (error) => { + exports.resetOAuth2ClientSecret(host, error => { callback(error, host); }); diff --git a/lib/machines.js b/lib/machines.js index 30db0093..5497f37b 100644 --- a/lib/machines.js +++ b/lib/machines.js @@ -74,13 +74,8 @@ exports.pull = function (projectId, callback) { const { host, _baseImage: image } = project.docker; const time = Date.now(); - docker.pullImage({ host, image }, (error, stream) => { - if (error) { - log('pull', image, error); - callback(new Error('Could not pull project')); - return; - } - + docker.pullImage({ host, image }).then(stream => { + let error; log('pull', image, 'started'); streams.set(project.docker, 'logs', stream); @@ -96,13 +91,7 @@ exports.pull = function (projectId, callback) { } // Inspect the pulled image to check its creation time. - docker.inspectImage({ host, image }, (error, data) => { - if (error) { - log('pull-inspect', image, error); - callback(new Error('Problem while inspecting image')); - return; - } - + docker.inspectImage({ host, image }).then(data => { const imageCreated = new Date(data.Created).getTime(); if (imageCreated <= project.data.updated) { // If the pulled image is as old as, or older than the Docker image @@ -115,21 +104,24 @@ exports.pull = function (projectId, callback) { // The pulled image is more recent than the one we currently use in // production. Let's use the newer image, by tagging it appropriately. const { _productionImage: tag } = project.docker; - docker.tagImage({ host, image, tag }, error => { - if (error) { - log('pull-tag', image, tag, error); - callback(new Error('Problem while tagging project')); - return; - } - + docker.tagImage({ host, image, tag }).then(() => { log('pull-tag', image, tag, 'success'); const now = Date.now(); metrics.set(project, 'updated', imageCreated); metrics.push(project, 'pull-time', [ now, now - time ]); callback(null, { image, created: imageCreated }); + }, error => { + log('pull-tag', image, tag, error); + callback(new Error('Problem while tagging project')); }); + }, error => { + log('pull-inspect', image, error); + callback(new Error('Problem while inspecting image')); }); }); + }, error => { + log('pull', image, error); + callback(new Error('Could not pull project')); }); }; @@ -144,15 +136,10 @@ exports.update = function (projectId, callback) { const { host, update: dockerfile, _productionImage: image } = project.docker; const time = Date.now(); - docker.buildImage({ host, tag: image, dockerfile }, (error, stream) => { - if (error) { - log('update', image, error); - callback(new Error('Unable to update project: ' + projectId)); - return; - } - + docker.buildImage({ host, tag: image, dockerfile }).then(stream => { log('update', image, 'started'); streams.set(project.docker, 'logs', stream); + let error; stream.on('error', err => { log('update', image, err); @@ -172,15 +159,17 @@ exports.update = function (projectId, callback) { metrics.push(project, 'update-time', [ now, now - time ]); callback(); }); + }, function (error) { + log('update', image, error); + callback(new Error('Unable to update project: ' + projectId)); }); }; // Instantiate a user machine for a project. (Fast!) -exports.spawn = function (user, projectId, callback) { +exports.spawn = async function (user, projectId) { const project = getProject(projectId); if (!project) { - callback(new Error('Unknown project: ' + projectId)); - return; + throw new Error('Unknown project: ' + projectId); } const machine = getOrCreateNewMachine(user, projectId); @@ -202,35 +191,34 @@ exports.spawn = function (user, projectId, callback) { const time = Date.now(); log('spawn', image, 'started'); - docker.runContainer({ host, image, ports }, (error, container, logs) => { - if (error) { - log('spawn', image, error); - callback(new Error('Unable to start machine for project: ' + projectId)); - return; - } - + let container; + try { + ({ container } = await docker.runContainer({ host, image, ports })); log('spawn', image, 'success', container.id.slice(0, 16)); machine.docker.container = container.id; machine.status = 'started'; + } catch (error) { + log('spawn', image, error); + throw new Error('Unable to start machine for project: ' + projectId); + } - const now = Date.now(); - metrics.push(project, 'spawn-time', [ now, now - time ]); - db.save(); + const now = Date.now(); + metrics.push(project, 'spawn-time', [ now, now - time ]); + 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); - }); + // 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); }); + + return machine; }; // Destroy a given user machine and recycle its ports. @@ -258,13 +246,7 @@ exports.destroy = function (user, projectId, machineId, callback) { } 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; - } - + docker.removeContainer({ host, container: containerId }, () => { // Recycle the machine's name and ports. machine.status = 'new'; machine.docker.container = ''; @@ -272,6 +254,9 @@ exports.destroy = function (user, projectId, machineId, callback) { log('destroy', containerId.slice(0, 16), 'success'); callback(); + }, error => { + log('destroy', containerId.slice(0, 16), error); + callback(error); }); }; @@ -296,41 +281,28 @@ exports.deployConfigurationInAllContainers = function (user, file) { }; // Install or overwrite a configuration file in a given user container. -exports.deployConfiguration = function (user, machine, file) { +exports.deployConfiguration = async function (user, machine, file) { const { host, container: containerId } = machine.docker; if (containerId.length < 16 || !/^[0-9a-f]+$/.test(containerId)) { - return Promise.reject(new Error('Invalid container ID: ' + containerId)); + throw new Error('Invalid container ID: ' + containerId); } - return new Promise((resolve, reject) => { - docker.copyIntoContainer({ - host, - container: containerId, - path: '/home/user/', - files: { - [file]: user.configurations[file], - } - }, error => { - if (error) { - reject(error); - return; - } + await docker.copyIntoContainer({ + host, + container: containerId, + path: '/home/user/', + files: { + [file]: user.configurations[file], + } + }); - // FIXME: Remove this workaround when the following Docker bug is fixed: - // https://github.com/docker/docker/issues/21651. - const command = `sudo chown user:user /home/user/${file}`; - docker.execInContainer({ - host, - container: containerId, - command - }, error => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); + // FIXME: Remove this workaround when the following Docker bug is fixed: + // https://github.com/docker/docker/issues/21651. + const command = `sudo chown user:user /home/user/${file}`; + return docker.execInContainer({ + host, + container: containerId, + command }); }; diff --git a/lib/proxy-heuristics.js b/lib/proxy-heuristics.js index 51aa8101..0851f16c 100644 --- a/lib/proxy-heuristics.js +++ b/lib/proxy-heuristics.js @@ -26,7 +26,7 @@ exports.handleProxyUrls = function (request, response, next) { // Look for a container ID and port in `request.url`. let match = exports.proxyUrlPrefix.exec(request.url); if (match) { - [url, containerId, port, path] = match; + [ url, containerId, port, path ] = match; // We want the proxied `path` to always begin with a '/'. // However `path` is empty in URLs like '/abc123/8080?p=1', so we redirect @@ -52,7 +52,7 @@ exports.handleProxyUrls = function (request, response, next) { const referer = nodeurl.parse(request.headers.referer); match = exports.proxyUrlPrefix.exec(referer.pathname); if (match) { - [url, containerId, port, path] = match; + [ url, containerId, port, path ] = match; } } diff --git a/lib/sessions.js b/lib/sessions.js index 1b5f2b69..3ad712f1 100644 --- a/lib/sessions.js +++ b/lib/sessions.js @@ -7,7 +7,7 @@ const db = require('./db'); const log = require('./log'); const login = new EmailLogin({ - db: './tokens/', + db: db.get('tokens', './tokens/'), mailer: db.get('mailer') }); const useSecureCookies = !db.get('security').forceInsecure; diff --git a/package.json b/package.json index bbeec69c..4ba208fe 100644 --- a/package.json +++ b/package.json @@ -18,14 +18,14 @@ "scripts": { "app": "SCRIPT=app npm start", "join": "SCRIPT=join npm start", - "lint": "eslint -c .eslintrc-node.js *.js api/ lib/ && eslint -c .eslintrc-browser.js static/", - "lint-fix": "eslint -c .eslintrc-node.js *.js api/ lib/ --fix && eslint -c .eslintrc-browser.js static/ --fix", + "lint": "eslint *.js api/ lib/ test/ static/", + "lint-fix": "eslint *.js api/ lib/ test/ static/ --fix", "rebase": "git pull -q --rebase origin master && git submodule -q update --rebase && npm update", "prestart": "npm stop && touch janitor.log janitor.pid && chmod 600 janitor.log janitor.pid", "start": "if [ -z \"$SCRIPT\" ] ; then printf \"Run which Janitor script? [join/app]:\" && read SCRIPT ; fi ; node \"$SCRIPT\" >> janitor.log 2>&1 & printf \"$!\\n\" > janitor.pid", "poststart": "printf \"[$(date -uIs)] Background process started (PID $(cat janitor.pid), LOGS $(pwd)/janitor.log).\\n\"", "stop": "if [ -e janitor.pid -a -n \"$(ps h $(cat janitor.pid))\" ] ; then kill $(cat janitor.pid) && printf \"[$(date -uIs)] Background process stopped (PID $(cat janitor.pid)).\\n\" ; fi ; rm -f janitor.pid", - "test": "cd tests && node tests.js", + "test": "jest", "prewatch": "touch janitor.log && chmod 600 janitor.log", "watch": "watch-run --initial --pattern 'app.js,package.json,api/**,lib/**,templates/**' --stop-on-error npm run app & tail -f janitor.log -n 0" }, @@ -52,6 +52,7 @@ "eslint-plugin-node": "^6.0.0", "eslint-plugin-promise": "^3.5.0", "eslint-plugin-standard": "^3.0.1", + "jest": "^22.4.3", "watch-run": "^1.2.5" }, "engines": { diff --git a/.eslintrc-browser.js b/static/.eslintrc.js similarity index 100% rename from .eslintrc-browser.js rename to static/.eslintrc.js diff --git a/test/.eslintrc.js b/test/.eslintrc.js new file mode 100644 index 00000000..7a2d6a8f --- /dev/null +++ b/test/.eslintrc.js @@ -0,0 +1,8 @@ +module.exports = { + "env": { + "jest": true + }, + "extends": [ + "../.eslintrc.js" + ] +}; \ No newline at end of file diff --git a/test/api.test.js b/test/api.test.js new file mode 100644 index 00000000..d4013b50 --- /dev/null +++ b/test/api.test.js @@ -0,0 +1,134 @@ +// Copyright © 2017 Jan Keromnes. All rights reserved. +// The following code is covered by the AGPL-3.0 license. + +'use strict'; + +const { Camp } = require('camp'); +const selfapi = require('selfapi'); +const stream = require('stream'); +const { promisify } = require('util'); + +describe('Janitor API self-tests', () => { + jest.mock('../lib/boot'); + jest.mock('../lib/db'); + jest.mock('../lib/docker'); + + const db = require('../lib/db'); + db.__setData({ + // Tell our fake Janitor app that it runs on the fake host "example.com" + hostname: 'example.com', + // Disable sending any emails (for invites or signing in) + mailer: { + block: true + }, + security: { + // Disable Let's Encrypt HTTPS certificate generation and verification + forceHttp: true, + // Disable any security policies that could get in the way of testing + forceInsecure: true + }, + tokens: 'test/tokens' + }); + + const boot = require('../lib/boot'); + boot.ensureDockerTlsCertificates.mockResolvedValue(); + + const docker = require('../lib/docker'); + const hosts = require('../lib/hosts'); + const machines = require('../lib/machines'); + const users = require('../lib/users'); + + // Fake several Docker methods for testing. + // TODO Maybe use a real Docker Remote API server (or a full mock) for more + // realistic tests? + docker.pullImage.mockImplementation(parameters => { + const readable = new stream.Readable(); + readable.push('ok'); + readable.push(null); // End the stream. + return Promise.resolve(readable); + }); + docker.inspectImage.mockResolvedValue({ Created: 1500000000000 }); + docker.tagImage.mockResolvedValue(); + docker.runContainer.mockResolvedValue({ container: { id: 'abcdef0123456789' }, logs: '' }); + docker.copyIntoContainer.mockResolvedValue(); + docker.execInContainer.mockResolvedValue(); + docker.listChangedFilesInContainer.mockResolvedValue([ + { Path: '/tmp', Kind: 0 }, + { Path: '/tmp/test', Kind: 1 } + ]); + docker.version.mockResolvedValue({ Version: '17.06.0-ce' }); + + const api = require('../api/'); + + const app = new Camp({ + documentRoot: 'static' + }); + + beforeAll(async () => { + function registerTestUser () { + // Grant administrative privileges to the fake email "admin@example.com". + db.get('admins')['admin@example.com'] = true; + // Create the user "admin@example.com" by "sending" them an invite email. + return promisify(users.sendInviteEmail)('admin@example.com'); + } + + function createTestHost () { + return promisify(hosts.create)('example.com', {}); + } + + function createTestProject () { + machines.setProject({ + 'id': 'test-project', + '/name': 'Test Project', + '/docker/host': 'example.com', + '/docker/image': 'image:latest', + }); + } + + function createTestContainer () { + const user = db.get('users')['admin@example.com']; + // Create a new user machine for the project "test-project". + return machines.spawn(user, 'test-project'); + } + + await Promise.all([ + registerTestUser(), + createTestHost(), + createTestProject(), + ]); + await createTestContainer(); + + // Authenticate test requests with a server middleware. + const sessions = require('../lib/sessions'); + app.handle((request, response, next) => { + sessions.get(request, (error, session, token) => { + if (error || !session || !session.id) { + console.error('[fail] session:', session, error); + response.statusCode = 500; // Internal Server Error + response.end(); + return; + } + request.session = session; + if (!('client_secret' in request.query)) { + request.user = db.get('users')['admin@example.com']; + } + next(); + }); + }); + + // Mount the Janitor API. + selfapi(app, '/api', api); + + await promisify(app.listen).call(app, 0, '127.0.0.1'); + }); + + afterAll(() => { + return promisify(app.close).call(app); + }); + + it('follows API examples', async () => { + const { passed, failed } = await promisify(api.test).call(api, `http://127.0.0.1:${app.address().port}`); + console.info(`${passed.length} passed, ${failed.length} failed`); + expect(failed).toHaveLength(0); + }); +}); diff --git a/tests/tests.js b/tests/tests.js deleted file mode 100644 index 085b000f..00000000 --- a/tests/tests.js +++ /dev/null @@ -1,257 +0,0 @@ -// Copyright © 2017 Jan Keromnes. All rights reserved. -// The following code is covered by the AGPL-3.0 license. - -'use strict'; - -const fs = require('fs'); -const net = require('net'); -const path = require('path'); -const selfapi = require('selfapi'); -const stream = require('stream'); - -if (path.basename(process.cwd()) !== 'tests') { - console.error('Warning: Tests need to run inside the tests/ folder.\n' + - ' This is to prevent interference between tests and production.\n' + - ' Teleporting to tests/ now.'); - process.chdir('tests'); -} - -try { - fs.unlinkSync('./db.json'); -} catch (error) { - if (error.code !== 'ENOENT') { - console.error(error.stack); - process.exit(1); - } -} - -try { - fs.symlinkSync('../templates', './templates', 'dir'); -} catch (error) { - if (error.code !== 'EEXIST') { - console.error(error.stack); - process.exit(1); - } -} - -let tests = []; - -tests.push({ - title: 'Janitor API self-tests', - - test: (port, callback) => { - const db = require('../lib/db'); - // Tell our fake Janitor app that it runs on the fake host "example.com": - db.get('hostname', 'example.com'); - // Disable sending any emails (for invites or signing in): - db.get('mailer').block = true; - // Disable Let's Encrypt HTTPS certificate generation and verification: - db.get('security').forceHttp = true; - // Disable any security policies that could get in the way of testing: - db.get('security').forceInsecure = true; - - const boot = require('../lib/boot'); - const docker = require('../lib/docker'); - const hosts = require('../lib/hosts'); - const machines = require('../lib/machines'); - const users = require('../lib/users'); - - // Fake several Docker methods for testing. - // TODO Maybe use a real Docker Remote API server (or a full mock) for more - // realistic tests? - docker.pullImage = function (parameters, callback) { - const readable = new stream.Readable(); - readable.push('ok'); - readable.push(null); // End the stream. - callback(null, readable); - }; - docker.inspectImage = function (parameters, callback) { - callback(null, { Created: 1500000000000 }); - }; - docker.tagImage = function (parameters, callback) { callback(); }; - docker.runContainer = function (parameters, callback) { - callback(null, { id: 'abcdef0123456789' }, ''); - }; - docker.copyIntoContainer = docker.execInContainer = - function (parameters, callback) { callback(); }; - docker.listChangedFilesInContainer = function (parameters, callback) { - callback(null, [ - { Path: '/tmp', Kind: 0 }, - { Path: '/tmp/test', Kind: 1 } - ]); - }; - docker.version = function (parameters, callback) { - callback(null, { Version: '17.06.0-ce' }); - }; - - function registerTestUser (next) { - // Grant administrative privileges to the fake email "admin@example.com". - db.get('admins')['admin@example.com'] = true; - // Create the user "admin@example.com" by "sending" them an invite email. - users.sendInviteEmail('admin@example.com', error => { - if (error) { - callback(error); - return; - } - next(); - }); - } - - function createTestHost (next) { - hosts.create('example.com', {}, (error, host) => { - if (error) { - callback(error); - return; - } - next(); - }); - } - - function createTestProject (next) { - machines.setProject({ - 'id': 'test-project', - '/name': 'Test Project', - '/docker/host': 'example.com', - '/docker/image': 'image:latest', - }); - next(); - } - - function createTestContainer (next) { - const user = db.get('users')['admin@example.com']; - // Create a new user machine for the project "test-project". - machines.spawn(user, 'test-project', error => { - if (error) { - callback(error); - return; - } - next(); - }); - } - - boot.executeInParallel([ - boot.forwardHttp, - boot.ensureDockerTlsCertificates, - registerTestUser, - createTestHost, - createTestProject, - ], () => { - createTestContainer(() => { - const camp = require('camp'); - const app = camp.start({ - documentRoot: process.cwd() + '/../static', - port: port - }); - - // Authenticate test requests with a server middleware. - const sessions = require('../lib/sessions'); - app.handle((request, response, next) => { - sessions.get(request, (error, session, token) => { - if (error || !session || !session.id) { - console.error('[fail] session:', session, error); - response.statusCode = 500; // Internal Server Error - response.end(); - return; - } - request.session = session; - if (!('client_secret' in request.query)) { - request.user = db.get('users')['admin@example.com']; - } - next(); - }); - }); - - // Mount the Janitor API. - const api = require('../api/'); - selfapi(app, '/api', api); - - // Test the API against its own examples. - api.test('http://localhost:' + port, (error, results) => { - if (error) { - callback(error); - return; - } - - if (results.failed.length > 0) { - var total = results.passed.length + results.failed.length; - callback(results.passed.length + '/' + total + ' API test' + - (total === 1 ? '' : 's') + ' passed. Failed tests: ' + - JSON.stringify(results.failed, null, 2)); - return; - } - - callback(); - }); - }); - }); - } -}); - -/* -tests.push({ - title: 'Docker host joining the cluster', - - test: (port, callback) => { - // TODO Start app (`node app` or similar) - // TODO Start cluster host (`node join` or similar, on different ports) - // TODO Verify that cluster registration works - // TODO verify that - callback(new Error('Not implemented yet')); - } -}); -*/ - -/** - * To add a new test, simply copy-paste and fill in the following code block: - -tests.push({ - title: '', - test: (port, callback) => { - // test some things - // callback(error); - } -}); - -*/ - -let nextPort = 9000; -function getPort (callback) { - const port = nextPort++; - const server = net.createServer(); - server.listen(port, (error) => { - server.once('close', () => callback(port)); - server.close(); - }); - server.on('error', error => getPort(callback)); -} - -let unfinishedTests = tests.length; -function reportTest (test, error) { - if (error) { - process.exitCode = 1; - console.error('[fail]', test.title); - console.error(...(error.stack ? [ error.stack ] : [ 'Error:', error ])); - } else { - console.log('[ok]', test.title); - } - unfinishedTests--; - if (unfinishedTests === 0) { - process.exit(); - } -} - -function runTest (test) { - getPort(port => { - try { - test.test(port, error => reportTest(test, error)); - } catch (error) { - reportTest(test, error); - } - }); -} - -while (tests.length > 0) { - // Randomly take a test out of the tests array, and run it. - const test = tests.splice(Math.floor(Math.random() * tests.length), 1)[0]; - runTest(test); -}