diff --git a/CHANGES b/CHANGES index 46565a93f..0bbc72a34 100644 --- a/CHANGES +++ b/CHANGES @@ -1923,4 +1923,5 @@ * Add UI to download backup config as JSON (and import it) * Ensure stopped apps are getting backed up * Add OVH Object Storage backend +* Add bind mounts (aka volumes) diff --git a/migrations/20200430035310-apps-add-bindsJson.js b/migrations/20200430035310-apps-add-bindsJson.js new file mode 100644 index 000000000..5b8c4ba96 --- /dev/null +++ b/migrations/20200430035310-apps-add-bindsJson.js @@ -0,0 +1,15 @@ +'use strict'; + +exports.up = function(db, callback) { + db.runSql('ALTER TABLE apps ADD COLUMN bindsJson TEXT', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE apps DROP COLUMN bindsJson', function (error) { + if (error) console.error(error); + callback(error); + }); +}; diff --git a/migrations/schema.sql b/migrations/schema.sql index e09d7c08d..f6e917d0a 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -86,6 +86,7 @@ CREATE TABLE IF NOT EXISTS apps( dataDir VARCHAR(256) UNIQUE, taskId INTEGER, // current task errorJson TEXT, + bindsJson TEXT, // bind mounts FOREIGN KEY(mailboxDomain) REFERENCES domains(domain), FOREIGN KEY(taskId) REFERENCES tasks(id), diff --git a/setup/start.sh b/setup/start.sh index c0fcef06b..8b06a9aae 100755 --- a/setup/start.sh +++ b/setup/start.sh @@ -20,6 +20,11 @@ readonly ubuntu_version=$(lsb_release -rs) cp -f "${script_dir}/../scripts/cloudron-support" /usr/bin/cloudron-support +# this needs to match the cloudron/base:2.0.0 gid +if ! getent group media; then + addgroup --gid 500 --system media +fi + echo "==> Configuring docker" cp "${script_dir}/start/docker-cloudron-app.apparmor" /etc/apparmor.d/docker-cloudron-app systemctl enable apparmor diff --git a/src/appdb.js b/src/appdb.js index 43461edf4..a799d191a 100644 --- a/src/appdb.js +++ b/src/appdb.js @@ -41,7 +41,7 @@ var assert = require('assert'), var APPS_FIELDS_PREFIXED = [ 'apps.id', 'apps.appStoreId', 'apps.installationState', 'apps.errorJson', 'apps.runState', 'apps.health', 'apps.containerId', 'apps.manifestJson', 'apps.httpPort', 'subdomains.subdomain AS location', 'subdomains.domain', 'apps.accessRestrictionJson', 'apps.memoryLimit', 'apps.cpuShares', - 'apps.label', 'apps.tagsJson', 'apps.taskId', 'apps.reverseProxyConfigJson', 'apps.serviceConfigJson', + 'apps.label', 'apps.tagsJson', 'apps.taskId', 'apps.reverseProxyConfigJson', 'apps.serviceConfigJson', 'apps.bindsJson', 'apps.sso', 'apps.debugModeJson', 'apps.enableBackup', 'apps.creationTime', 'apps.updateTime', 'apps.mailboxName', 'apps.mailboxDomain', 'apps.enableAutomaticUpdate', 'apps.dataDir', 'apps.ts', 'apps.healthTime' ].join(','); @@ -98,6 +98,10 @@ function postProcess(result) { result.serviceConfig = safe.JSON.parse(result.serviceConfigJson) || {}; delete result.serviceConfigJson; + assert(result.bindsJson === null || typeof result.bindsJson === 'string'); + result.binds = safe.JSON.parse(result.bindsJson) || {}; + delete result.bindsJson; + result.alternateDomains = result.alternateDomains || []; result.alternateDomains.forEach(function (d) { delete d.appId; @@ -431,7 +435,7 @@ function updateWithConstraints(id, app, constraints, callback) { var fields = [ ], values = [ ]; for (var p in app) { - if (p === 'manifest' || p === 'tags' || p === 'accessRestriction' || p === 'debugMode' || p === 'error' || p === 'reverseProxyConfig' || p === 'serviceConfig') { + if (p === 'manifest' || p === 'tags' || p === 'accessRestriction' || p === 'debugMode' || p === 'error' || p === 'reverseProxyConfig' || p === 'serviceConfig' || p === 'binds') { fields.push(`${p}Json = ?`); values.push(JSON.stringify(app[p])); } else if (p !== 'portBindings' && p !== 'location' && p !== 'domain' && p !== 'alternateDomains' && p !== 'env') { diff --git a/src/apps.js b/src/apps.js index f137f1085..55d2b5159 100644 --- a/src/apps.js +++ b/src/apps.js @@ -20,6 +20,7 @@ exports = module.exports = { setTags: setTags, setMemoryLimit: setMemoryLimit, setCpuShares: setCpuShares, + setBinds: setBinds, setAutomaticBackup: setAutomaticBackup, setAutomaticUpdate: setAutomaticUpdate, setReverseProxyConfig: setReverseProxyConfig, @@ -332,6 +333,20 @@ function validateEnv(env) { return null; } +function validateBinds(binds) { + for (let name of Object.keys(binds)) { + // just have friendly characters under /media + if (!/^[-0-9a-zA-Z_@$=#.%+]+$/.test(name)) return new BoxError(BoxError.BAD_FIELD, 'Invalid bind name'); + + const bind = binds[name]; + + if (!bind.hostPath.startsWith('/mnt') && !bind.hostPath.startsWith('/media')) return new BoxError(BoxError.BAD_FIELD, 'hostPath must be in /mnt or /media'); + if (path.normalize(bind.hostPath) !== bind.hostPath) return new BoxError(BoxError.BAD_FIELD, 'hostPath is not normalized'); + } + + return null; +} + function validateDataDir(dataDir) { if (dataDir === null) return null; @@ -404,7 +419,7 @@ function removeInternalFields(app) { 'location', 'domain', 'fqdn', 'mailboxName', 'mailboxDomain', 'accessRestriction', 'manifest', 'portBindings', 'iconUrl', 'memoryLimit', 'cpuShares', 'sso', 'debugMode', 'reverseProxyConfig', 'enableBackup', 'creationTime', 'updateTime', 'ts', 'tags', - 'label', 'alternateDomains', 'env', 'enableAutomaticUpdate', 'dataDir'); + 'label', 'alternateDomains', 'env', 'enableAutomaticUpdate', 'dataDir', 'binds'); } // non-admins can only see these @@ -968,6 +983,32 @@ function setCpuShares(app, cpuShares, auditSource, callback) { }); } +function setBinds(app, binds, auditSource, callback) { + assert.strictEqual(typeof app, 'object'); + assert(binds && typeof binds === 'object'); + assert.strictEqual(typeof auditSource, 'object'); + assert.strictEqual(typeof callback, 'function'); + + const appId = app.id; + let error = checkAppState(app, exports.ISTATE_PENDING_RECREATE_CONTAINER); + if (error) return callback(error); + + error = validateBinds(binds); + if (error) return callback(error); + + const task = { + args: {}, + values: { binds } + }; + addTask(appId, exports.ISTATE_PENDING_RECREATE_CONTAINER, task, function (error, result) { + if (error) return callback(error); + + eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, binds, taskId: result.taskId }); + + callback(null, { taskId: result.taskId }); + }); +} + function setEnvironment(app, env, auditSource, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof env, 'object'); diff --git a/src/docker.js b/src/docker.js index 95b07085a..ebd08856d 100644 --- a/src/docker.js +++ b/src/docker.js @@ -188,6 +188,19 @@ function downloadImage(manifest, callback) { }, callback); } +function getBindsSync(app) { + assert.strictEqual(typeof app, 'object'); + + let binds = []; + + for (let name of Object.keys(app.binds)) { + const bind = app.binds[name]; + binds.push(`${bind.hostPath}:/media/${name}:${bind.readOnly ? 'ro' : 'rw'}`); + } + + return binds; +} + function createSubcontainer(app, name, cmd, options, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof name, 'string'); @@ -277,6 +290,7 @@ function createSubcontainer(app, name, cmd, options, callback) { }, HostConfig: { Mounts: addons.getMountsSync(app, app.manifest.addons), + Binds: getBindsSync(app), // ideally, we have to use 'Mounts' but we have to create volumes then LogConfig: { Type: 'syslog', Config: { diff --git a/src/routes/apps.js b/src/routes/apps.js index 313e2eb70..fc43483ac 100644 --- a/src/routes/apps.js +++ b/src/routes/apps.js @@ -30,6 +30,7 @@ exports = module.exports = { setMailbox: setMailbox, setLocation: setLocation, setDataDir: setDataDir, + setBinds: setBinds, stop: stop, start: start, @@ -764,3 +765,23 @@ function downloadFile(req, res, next) { stream.pipe(res); }); } + +function setBinds(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + assert.strictEqual(typeof req.resource, 'object'); + + if (!req.body.binds || typeof req.body.binds !== 'object') return next(new HttpError(400, 'binds should be an object')); + + for (let name of Object.keys(req.body.binds)) { + if (!req.body.binds[name] || typeof req.body.binds[name] !== 'object') return next(new HttpError(400, 'each bind should be an object')); + if (typeof req.body.binds[name].hostPath !== 'string') return next(new HttpError(400, 'hostPath must be a string')); + if (typeof req.body.binds[name].readOnly !== 'boolean') return next(new HttpError(400, 'readOnly must be a boolean')); + } + + apps.setBinds(req.resource, req.body.binds, auditSource.fromRequest(req), function (error, result) { + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(202, { taskId: result.taskId })); + }); +} + diff --git a/src/server.js b/src/server.js index b1b8c71ef..f32a6bd65 100644 --- a/src/server.js +++ b/src/server.js @@ -212,6 +212,7 @@ function initializeExpressSync() { router.post('/api/v1/apps/:id/configure/env', token, authorizeAdmin, routes.apps.load, routes.apps.setEnvironment); router.post('/api/v1/apps/:id/configure/data_dir', token, authorizeAdmin, routes.apps.load, routes.apps.setDataDir); router.post('/api/v1/apps/:id/configure/location', token, authorizeAdmin, routes.apps.load, routes.apps.setLocation); + router.post('/api/v1/apps/:id/configure/binds', token, authorizeAdmin, routes.apps.load, routes.apps.setBinds); router.post('/api/v1/apps/:id/repair', token, authorizeAdmin, routes.apps.load, routes.apps.repair); router.post('/api/v1/apps/:id/update', token, authorizeAdmin, routes.apps.load, routes.apps.update); diff --git a/src/test/database-test.js b/src/test/database-test.js index 677f0ccac..7ff6bf20c 100644 --- a/src/test/database-test.js +++ b/src/test/database-test.js @@ -419,6 +419,7 @@ describe('database', function () { tags: [], label: null, taskId: null, + binds: {}, serviceConfig: {} }; @@ -891,6 +892,7 @@ describe('database', function () { tags: [], label: null, taskId: null, + binds: {}, serviceConfig: {} }; @@ -923,6 +925,7 @@ describe('database', function () { tags: [], label: null, taskId: null, + binds: {}, serviceConfig: {} };