diff --git a/CHANGES b/CHANGES index a00525855..a6b32ad65 100644 --- a/CHANGES +++ b/CHANGES @@ -2350,4 +2350,5 @@ * sftp: normal users do not have SFTP access anymore. Use operator role instead * eventlog: add service rebuild/restart/configure events * upcloud: add object storage integration +* Each app can now have a custom crontab diff --git a/migrations/20210927211658-apps-add-crontab.js b/migrations/20210927211658-apps-add-crontab.js new file mode 100644 index 000000000..c1923a1c3 --- /dev/null +++ b/migrations/20210927211658-apps-add-crontab.js @@ -0,0 +1,9 @@ +'use strict'; + +exports.up = function(db, callback) { + db.runSql('ALTER TABLE apps ADD COLUMN crontab TEXT', callback); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE apps DROP COLUMN crontab', callback); +}; diff --git a/src/apps.js b/src/apps.js index 2569be4b0..80bdaf8c6 100644 --- a/src/apps.js +++ b/src/apps.js @@ -26,6 +26,7 @@ exports = module.exports = { setAccessRestriction, setOperators, + setCrontab, setLabel, setIcon, setTags, @@ -80,6 +81,7 @@ exports = module.exports = { getIcon, getMemoryLimit, getLimits, + getSchedulerConfig, listEventlog, @@ -131,6 +133,7 @@ exports = module.exports = { _validatePortBindings: validatePortBindings, _validateAccessRestriction: validateAccessRestriction, _translatePortBindings: translatePortBindings, + _parseCrontab: parseCrontab, _clear: clear }; @@ -140,6 +143,7 @@ const appstore = require('./appstore.js'), backups = require('./backups.js'), BoxError = require('./boxerror.js'), constants = require('./constants.js'), + CronJob = require('cron').CronJob, database = require('./database.js'), debug = require('debug')('box:apps'), dns = require('./dns.js'), @@ -173,7 +177,7 @@ const appstore = require('./appstore.js'), const APPS_FIELDS_PREFIXED = [ 'apps.id', 'apps.appStoreId', 'apps.installationState', 'apps.errorJson', 'apps.runState', 'apps.health', 'apps.containerId', 'apps.manifestJson', 'apps.accessRestrictionJson', 'apps.memoryLimit', 'apps.cpuShares', 'apps.label', 'apps.tagsJson', 'apps.taskId', 'apps.reverseProxyConfigJson', 'apps.servicesConfigJson', 'apps.operatorsJson', - 'apps.sso', 'apps.debugModeJson', 'apps.enableBackup', 'apps.proxyAuth', 'apps.containerIp', + 'apps.sso', 'apps.debugModeJson', 'apps.enableBackup', 'apps.proxyAuth', 'apps.containerIp', 'apps.crontab', 'apps.creationTime', 'apps.updateTime', 'apps.enableMailbox', 'apps.mailboxName', 'apps.mailboxDomain', 'apps.enableAutomaticUpdate', 'apps.dataDir', 'apps.ts', 'apps.healthTime', '(apps.icon IS NOT NULL) AS hasIcon', '(apps.appStoreIcon IS NOT NULL) AS hasAppStoreIcon' ].join(','); @@ -261,6 +265,50 @@ function translatePortBindings(portBindings, manifest) { return result; } +function parseCrontab(crontab) { + assert(crontab === null || typeof crontab === 'string'); + + const result = []; + if (!crontab) return result; + + const lines = crontab.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line || line.startsWith('#')) continue; + const parts = /(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.*)/.exec(line); + if (!parts) throw new BoxError(BoxError.BAD_FIELD, `Invalid cron configuration at line ${i+1}`); + const schedule = parts.slice(1, 6).join(' '); + const command = parts[6]; + + try { + new CronJob('00 ' + schedule, function() {}); // second is disallowed + } catch (ex) { + throw new BoxError(BoxError.BAD_FIELD, `Invalid cron pattern at line ${i+1}`); + } + + if (command.length === 0) throw new BoxError(BoxError.BAD_FIELD, `Invalid cron pattern. Command must not be empty at line ${i+1}`); + + result.push({ schedule, command }); + } + + return result; +} + +function getSchedulerConfig(app) { + assert.strictEqual(typeof app, 'object'); + + let schedulerConfig = app.manifest.addons?.scheduler || null; + + const crontab = parseCrontab(app.crontab); + if (crontab.length === 0) return schedulerConfig; + + schedulerConfig = schedulerConfig || {}; + // put a '.' because it is not a valid name for schedule name in manifestformat + crontab.forEach((c, idx) => schedulerConfig[`crontab.${idx}`] = c); + + return schedulerConfig; +} + // also validates operators function validateAccessRestriction(accessRestriction) { assert.strictEqual(typeof accessRestriction, 'object'); @@ -444,7 +492,7 @@ function getDataDir(app, dataDir) { function removeInternalFields(app) { return _.pick(app, 'id', 'appStoreId', 'installationState', 'error', 'runState', 'health', 'taskId', - 'location', 'domain', 'fqdn', 'mailboxName', 'mailboxDomain', + 'location', 'domain', 'fqdn', 'mailboxName', 'mailboxDomain', 'crontab', 'accessRestriction', 'manifest', 'portBindings', 'iconUrl', 'memoryLimit', 'cpuShares', 'operators', 'sso', 'debugMode', 'reverseProxyConfig', 'enableBackup', 'creationTime', 'updateTime', 'ts', 'tags', 'label', 'alternateDomains', 'aliasDomains', 'env', 'enableAutomaticUpdate', 'dataDir', 'mounts', 'enableMailbox'); @@ -1226,13 +1274,25 @@ async function setOperators(app, operators, auditSource) { assert.strictEqual(typeof auditSource, 'object'); const appId = app.id; - let error = validateAccessRestriction(operators); // not a typo. same structure for operators and accessRestriction + const error = validateAccessRestriction(operators); // not a typo. same structure for operators and accessRestriction if (error) throw error; await update(appId, { operators }); await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, operators }); } +async function setCrontab(app, crontab, auditSource) { + assert.strictEqual(typeof app, 'object'); + assert(crontab === null || typeof crontab === 'string'); + assert.strictEqual(typeof auditSource, 'object'); + + const appId = app.id; + parseCrontab(crontab); + + await update(appId, { crontab }); + await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, crontab }); +} + async function setLabel(app, label, auditSource) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof label, 'string'); diff --git a/src/routes/apps.js b/src/routes/apps.js index 8a4dd7356..8b92a8fcd 100644 --- a/src/routes/apps.js +++ b/src/routes/apps.js @@ -20,6 +20,7 @@ exports = module.exports = { setAccessRestriction, setOperators, + setCrontab, setLabel, setTags, setIcon, @@ -196,6 +197,18 @@ async function setOperators(req, res, next) { next(new HttpSuccess(200, {})); } +async function setCrontab(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + assert.strictEqual(typeof req.app, 'object'); + + if (req.body.crontab !== null && typeof req.body.crontab !== 'string') return next(new HttpError(400, 'crontab must be a string')); + + const [error] = await safe(apps.setCrontab(req.app, req.body.crontab, auditSource.fromRequest(req))); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(200, {})); +} + async function setLabel(req, res, next) { assert.strictEqual(typeof req.body, 'object'); assert.strictEqual(typeof req.app, 'object'); diff --git a/src/scheduler.js b/src/scheduler.js index ab7b2d723..301dfd120 100644 --- a/src/scheduler.js +++ b/src/scheduler.js @@ -112,7 +112,7 @@ async function sync() { const allApps = await apps.list(); - const allAppIds = allApps.map(function (app) { return app.id; }); + const allAppIds = allApps.map(app => app.id); const removedAppIds = _.difference(Object.keys(gState), allAppIds); if (removedAppIds.length !== 0) debug(`sync: stopping jobs of removed apps ${JSON.stringify(removedAppIds)}`); @@ -126,7 +126,7 @@ async function sync() { for (const app of allApps) { const appState = gState[app.id] || null; - const schedulerConfig = app.manifest.addons ? app.manifest.addons.scheduler : null; + const schedulerConfig = apps.getSchedulerConfig(app); if (!appState && !schedulerConfig) continue; // nothing to do if (appState && appState.cronJobs) { // we had created jobs for this app previously diff --git a/src/server.js b/src/server.js index fbc5eea11..9cd7e7d37 100644 --- a/src/server.js +++ b/src/server.js @@ -220,6 +220,7 @@ function initializeExpressSync() { router.post('/api/v1/apps/:id/configure/data_dir', json, token, routes.apps.load, authorizeAdmin, routes.apps.setDataDir); router.post('/api/v1/apps/:id/configure/location', json, token, routes.apps.load, authorizeAdmin, routes.apps.setLocation); router.post('/api/v1/apps/:id/configure/mounts', json, token, routes.apps.load, authorizeAdmin, routes.apps.setMounts); + router.post('/api/v1/apps/:id/configure/cron', json, token, routes.apps.load, authorizeOperator, routes.apps.setCrontab); router.post('/api/v1/apps/:id/repair', json, token, routes.apps.load, authorizeOperator, routes.apps.repair); router.post('/api/v1/apps/:id/check_for_updates', json, token, routes.apps.load, authorizeOperator, routes.apps.checkForUpdates); router.post('/api/v1/apps/:id/update', json, token, routes.apps.load, authorizeOperator, routes.apps.update);