diff --git a/CHANGES b/CHANGES index d0dcb6df2..98bf59d77 100644 --- a/CHANGES +++ b/CHANGES @@ -2477,4 +2477,6 @@ * Refactor backup code to use async/await * mongodb: fix bug where a small timeout prevented import of large backups * Add update available filter +* exec: rework API to get exit code +* Add profile backgroundImage api diff --git a/src/apps.js b/src/apps.js index 86b854ef3..03bf32924 100644 --- a/src/apps.js +++ b/src/apps.js @@ -66,7 +66,9 @@ exports = module.exports = { stop, restart, - exec, + createExec, + startExec, + getExec, checkManifestConstraints, downloadManifest, @@ -2335,7 +2337,7 @@ function checkManifestConstraints(manifest) { return null; } -async function exec(app, options) { +async function createExec(app, options) { assert.strictEqual(typeof app, 'object'); assert(options && typeof options === 'object'); @@ -2346,7 +2348,7 @@ async function exec(app, options) { throw new BoxError(BoxError.BAD_STATE, 'App not installed or running'); } - const execOptions = { + const createOptions = { AttachStdin: true, AttachStdout: true, AttachStderr: true, @@ -2358,6 +2360,18 @@ async function exec(app, options) { Cmd: cmd }; + return await docker.createExec(app.containerId, createOptions); +} + +async function startExec(app, execId, options) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof execId, 'string'); + assert(options && typeof options === 'object'); + + if (app.installationState !== exports.ISTATE_INSTALLED || app.runState !== exports.RSTATE_RUNNING) { + throw new BoxError(BoxError.BAD_STATE, 'App not installed or running'); + } + const startOptions = { Detach: false, Tty: options.tty, @@ -2373,10 +2387,26 @@ async function exec(app, options) { stderr: true }; - const stream = await docker.execContainer(app.containerId, { execOptions, startOptions, rows: options.rows, columns: options.columns }); + const stream = await docker.startExec(execId, startOptions); + + if (options.rows && options.columns) { + // there is a race where resizing too early results in a 404 "no such exec" + // https://git.cloudron.io/cloudron/box/issues/549 + setTimeout(async function () { + await safe(docker.resizeExec(execId, { h: options.rows, w: options.columns }, { debug })); + }, 2000); + } + return stream; } +async function getExec(app, execId) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof execId, 'string'); + + return await docker.getExec(execId); +} + function canAutoupdateApp(app, updateInfo) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof updateInfo, 'object'); @@ -2601,7 +2631,7 @@ async function downloadFile(app, filePath) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof filePath, 'string'); - const statStream = await exec(app, { cmd: [ 'stat', '--printf=%F-%s', filePath ], tty: true }); + const statStream = await startExec(app, { cmd: [ 'stat', '--printf=%F-%s', filePath ], tty: true }); const data = await drainStream(statStream); const parts = data.split('-'); @@ -2622,7 +2652,7 @@ async function downloadFile(app, filePath) { throw new BoxError(BoxError.NOT_FOUND, 'only files or dirs can be downloaded'); } - const inputStream = await exec(app, { cmd, tty: false }); + const inputStream = await startExec(app, { cmd, tty: false }); // transforms the docker stream into a normal stream const stdoutStream = new TransformStream({ @@ -2663,7 +2693,7 @@ async function uploadFile(app, sourceFilePath, destFilePath) { const escapedDestFilePath = safe.child_process.execSync(`printf %q '${destFilePath.replace(/'/g, '\'\\\'\'')}'`, { shell: '/bin/bash', encoding: 'utf8' }); debug(`uploadFile: ${sourceFilePath} -> ${escapedDestFilePath}`); - const destStream = await exec(app, { cmd: [ 'bash', '-c', `cat - > ${escapedDestFilePath}` ], tty: false }); + const destStream = await startExec(app, { cmd: [ 'bash', '-c', `cat - > ${escapedDestFilePath}` ], tty: false }); return new Promise((resolve, reject) => { const done = once(error => reject(new BoxError(BoxError.FS_ERROR, error.message))); diff --git a/src/docker.js b/src/docker.js index 23de06a46..acc8f65ff 100644 --- a/src/docker.js +++ b/src/docker.js @@ -20,13 +20,19 @@ exports = module.exports = { createSubcontainer, inspect, getContainerIp, - execContainer, getEvents, memoryUsage, + createVolume, removeVolume, clearVolume, + update, + + createExec, + startExec, + getExec, + resizeExec }; const apps = require('./apps.js'), @@ -558,30 +564,51 @@ async function getContainerIp(containerId) { return ip; } -async function execContainer(containerId, options) { +async function createExec(containerId, options) { assert.strictEqual(typeof containerId, 'string'); assert.strictEqual(typeof options, 'object'); const container = gConnection.getContainer(containerId); - - const [error, exec] = await safe(container.exec(options.execOptions)); + const [error, exec] = await safe(container.exec(options)); + if (error && error.statusCode === 404) throw new BoxError(BoxError.NOT_FOUND); if (error && error.statusCode === 409) throw new BoxError(BoxError.BAD_STATE, error.message); // container restarting/not running if (error) throw new BoxError(BoxError.DOCKER_ERROR, error); - const [startError, stream] = await safe(exec.start(options.startOptions)); /* in hijacked mode, stream is a net.socket */ - if (startError) throw new BoxError(BoxError.DOCKER_ERROR, startError); + return exec.id; +} - if (options.rows && options.columns) { - // there is a race where resizing too early results in a 404 "no such exec" - // https://git.cloudron.io/cloudron/box/issues/549 - setTimeout(function () { - exec.resize({ h: options.rows, w: options.columns }, function (error) { if (error) debug('Error resizing console', error); }); - }, 2000); - } +async function startExec(execId, options) { + assert.strictEqual(typeof execId, 'string'); + assert.strictEqual(typeof options, 'object'); + const exec = gConnection.getExec(execId); + const [error, stream] = await safe(exec.start(options)); /* in hijacked mode, stream is a net.socket */ + if (error && error.statusCode === 404) throw new BoxError(BoxError.NOT_FOUND); + if (error) throw new BoxError(BoxError.DOCKER_ERROR, error); return stream; } +async function getExec(execId) { + assert.strictEqual(typeof execId, 'string'); + + const exec = gConnection.getExec(execId); + const [error, result] = await safe(exec.inspect()); + if (error && error.statusCode === 404) throw new BoxError(BoxError.NOT_FOUND, `Unable to find exec container ${execId}`); + if (error) throw new BoxError(BoxError.DOCKER_ERROR, error); + + return { exitCode: result.ExitCode, running: result.Running }; +} + +async function resizeExec(execId, options) { + assert.strictEqual(typeof execId, 'string'); + assert.strictEqual(typeof options, 'object'); + + const exec = gConnection.getExec(execId); + const [error] = await safe(exec.resize(options)); // { h, w } + if (error && error.statusCode === 404) throw new BoxError(BoxError.NOT_FOUND); + if (error) throw new BoxError(BoxError.DOCKER_ERROR, error); +} + async function getEvents(options) { assert.strictEqual(typeof options, 'object'); diff --git a/src/nginxconfig.ejs b/src/nginxconfig.ejs index 9fc3f81f6..de8474c57 100644 --- a/src/nginxconfig.ejs +++ b/src/nginxconfig.ejs @@ -216,7 +216,7 @@ server { } # the read timeout is between successive reads and not the whole connection - location ~ ^/api/v1/apps/.*/exec$ { + location ~ ^/api/v1/apps/.*/exec/.*/start$ { proxy_pass http://127.0.0.1:3000; proxy_read_timeout 30m; } diff --git a/src/routes/apps.js b/src/routes/apps.js index d3de6adf8..ac0e43915 100644 --- a/src/routes/apps.js +++ b/src/routes/apps.js @@ -41,8 +41,12 @@ exports = module.exports = { stop, start, restart, - exec, - execWebSocket, + + createExec, + startExec, + startExecWebSocket, + getExec, + checkForUpdates, clone, @@ -697,14 +701,29 @@ function demuxStream(stream, stdin) { }); } -async function exec(req, res, next) { +async function createExec(req, res, next) { assert.strictEqual(typeof req.app, 'object'); + assert.strictEqual(typeof req.body, 'object'); - let cmd = null; - if (req.query.cmd) { - cmd = safe.JSON.parse(req.query.cmd); - if (!Array.isArray(cmd) || cmd.length < 1) return next(new HttpError(400, 'cmd must be array with atleast size 1')); + if ('cmd' in req.body) { + if (!Array.isArray(req.body.cmd) || req.body.cmd.length < 1) return next(new HttpError(400, 'cmd must be array with atleast size 1')); } + const cmd = req.body.cmd || null; + + if ('tty' in req.body && typeof req.body.tty !== 'boolean') return next(new HttpError(400, 'tty must be boolean')); + const tty = !!req.body.tty; + + if (safe.query(req.app, 'manifest.addons.docker') && req.user.role !== users.ROLE_OWNER) return next(new HttpError(403, '"owner" role is requied to exec app with docker addon')); + + const [error, id] = await safe(apps.createExec(req.app, { cmd, tty })); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(200, { id })); +} + +async function startExec(req, res, next) { + assert.strictEqual(typeof req.app, 'object'); + assert.strictEqual(typeof req.params.execId, 'string'); const columns = req.query.columns ? parseInt(req.query.columns, 10) : null; if (isNaN(columns)) return next(new HttpError(400, 'columns must be a number')); @@ -719,7 +738,7 @@ async function exec(req, res, next) { // in a badly configured reverse proxy, we might be here without an upgrade if (req.headers['upgrade'] !== 'tcp') return next(new HttpError(404, 'exec requires TCP upgrade')); - const [error, duplexStream] = await safe(apps.exec(req.app, { cmd: cmd, rows: rows, columns: columns, tty: tty })); + const [error, duplexStream] = await safe(apps.startExec(req.app, req.params.execId, { rows, columns, tty })); if (error) return next(BoxError.toHttpError(error)); req.clearTimeout(); @@ -737,8 +756,9 @@ async function exec(req, res, next) { } } -async function execWebSocket(req, res, next) { +async function startExecWebSocket(req, res, next) { assert.strictEqual(typeof req.app, 'object'); + assert.strictEqual(typeof req.params.execId, 'string'); let cmd = null; if (req.query.cmd) { @@ -757,7 +777,7 @@ async function execWebSocket(req, res, next) { // in a badly configured reverse proxy, we might be here without an upgrade if (req.headers['upgrade'] !== 'websocket') return next(new HttpError(404, 'exec requires websocket')); - const [error, duplexStream] = await safe(apps.exec(req.app, { cmd: cmd, rows: rows, columns: columns, tty: tty })); + const [error, duplexStream] = await safe(apps.startExec(req.app, { cmd: cmd, rows: rows, columns: columns, tty: tty })); if (error) return next(BoxError.toHttpError(error)); req.clearTimeout(); @@ -785,6 +805,15 @@ async function execWebSocket(req, res, next) { }); } +async function getExec(req, res, next) { + assert.strictEqual(typeof req.app, 'object'); + assert.strictEqual(typeof req.params.execId, 'string'); + + const [error, result] = await safe(apps.getExec(req.app, req.params.execId)); + if (error) return next(BoxError.toHttpError(error)); + next(new HttpSuccess(200, result)); // { exitCode, running } +} + async function listBackups(req, res, next) { assert.strictEqual(typeof req.app, 'object'); diff --git a/src/server.js b/src/server.js index 6e701a3dc..0884292ee 100644 --- a/src/server.js +++ b/src/server.js @@ -250,10 +250,12 @@ function initializeExpressSync() { router.get ('/api/v1/apps/:id/download', token, routes.apps.load, authorizeOperator, routes.apps.downloadFile); router.post('/api/v1/apps/:id/upload', json, token, multipart, routes.apps.load, authorizeOperator, routes.apps.uploadFile); router.use ('/api/v1/apps/:id/files/*', token, routes.apps.load, authorizeOperator, routes.filemanager.proxy('app')); - router.get ('/api/v1/apps/:id/exec', token, routes.apps.load, authorizeOperator, routes.apps.exec); + router.post('/api/v1/apps/:id/exec', json, token, routes.apps.load, authorizeOperator, routes.apps.createExec); + router.get ('/api/v1/apps/:id/exec/:execId/start', token, routes.apps.load, authorizeOperator, routes.apps.startExec); + router.get ('/api/v1/apps/:id/exec/:execId', token, routes.apps.load, authorizeOperator, routes.apps.getExec); // websocket cannot do bearer authentication - router.get ('/api/v1/apps/:id/execws', token, routes.apps.load, routes.accesscontrol.authorizeOperator, routes.apps.execWebSocket); + router.get ('/api/v1/apps/:id/exec/:execId/startws', token, routes.apps.load, routes.accesscontrol.authorizeOperator, routes.apps.startExecWebSocket); // branding routes router.get ('/api/v1/branding/:setting', token, authorizeOwner, routes.branding.get);