diff --git a/CHANGES b/CHANGES index 2da47efeb..05ee3c4ec 100644 --- a/CHANGES +++ b/CHANGES @@ -2463,4 +2463,5 @@ * sshfs: fix bug where sshfs mounts were generated without unbound dependancy * cloudron-setup: add --setup-token * notifications: add installation event +* backups: set label of backup and control it's retention diff --git a/migrations/20220402233643-backups-add-label.js b/migrations/20220402233643-backups-add-label.js new file mode 100644 index 000000000..1d31e5bfd --- /dev/null +++ b/migrations/20220402233643-backups-add-label.js @@ -0,0 +1,16 @@ +'use strict'; + +exports.up = function(db, callback) { + db.runSql('ALTER TABLE backups ADD COLUMN label VARCHAR(128) DEFAULT ""', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE backups DROP COLUMN label', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + diff --git a/migrations/schema.sql b/migrations/schema.sql index 9de1c6bff..3b2bbbd3b 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -133,6 +133,7 @@ CREATE TABLE IF NOT EXISTS appEnvVars( CREATE TABLE IF NOT EXISTS backups( id VARCHAR(128) NOT NULL, + label VARCHAR(128) DEFAULT "", creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, packageVersion VARCHAR(128) NOT NULL, /* app version or box version */ encryptionVersion INTEGER, /* when null, unencrypted backup */ diff --git a/src/apps.js b/src/apps.js index a07090d11..59738d4ff 100644 --- a/src/apps.js +++ b/src/apps.js @@ -54,6 +54,7 @@ exports = module.exports = { backup, listBackups, + updateBackup, getTask, getLogPaths, @@ -2452,6 +2453,18 @@ async function listBackups(app, page, perPage) { return await backups.getByIdentifierAndStatePaged(app.id, backups.BACKUP_STATE_NORMAL, page, perPage); } +async function updateBackup(app, backupId, data) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof backupId, 'string'); + assert.strictEqual(typeof data, 'object'); + + const backup = await backups.get(backupId); + if (!backup) throw new BoxError(BoxError.NOT_FOUND, 'Backup not found'); + if (backup.identifier !== app.id) throw new BoxError(BoxError.NOT_FOUND, 'Backup not found'); // some other app's backup + + await backups.update(backupId, data); +} + async function restoreInstalledApps(options, auditSource) { assert.strictEqual(typeof options, 'object'); assert.strictEqual(typeof auditSource, 'object'); diff --git a/src/backups.js b/src/backups.js index 3507745ee..639bd7da5 100644 --- a/src/backups.js +++ b/src/backups.js @@ -63,7 +63,7 @@ const assert = require('assert'), const COLLECTD_CONFIG_EJS = fs.readFileSync(__dirname + '/collectd/cloudron-backup.ejs', { encoding: 'utf8' }); -const BACKUPS_FIELDS = [ 'id', 'identifier', 'creationTime', 'packageVersion', 'type', 'dependsOn', 'state', 'manifestJson', 'format', 'preserveSecs', 'encryptionVersion' ]; +const BACKUPS_FIELDS = [ 'id', 'label', 'identifier', 'creationTime', 'packageVersion', 'type', 'dependsOn', 'state', 'manifestJson', 'format', 'preserveSecs', 'encryptionVersion' ]; // helper until all storage providers have been ported function maybePromisify(func) { @@ -172,14 +172,31 @@ async function getByTypePaged(type, page, perPage) { return results; } -async function update(id, backup) { - assert.strictEqual(typeof id, 'string'); - assert.strictEqual(typeof backup, 'object'); +function validateLabel(label) { + assert.strictEqual(typeof label, 'string'); - let fields = [ ], values = [ ]; - for (const p in backup) { - fields.push(p + ' = ?'); - values.push(backup[p]); + if (label.length >= 200) return new BoxError(BoxError.BAD_FIELD, 'label too long'); + if (/[^a-zA-Z0-9._()-]/.test(label)) return new BoxError(BoxError.BAD_FIELD, 'label can only contain alphanumerals, dot, hyphen, brackets or underscore'); + + return null; +} + +async function update(id, data) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof data, 'object'); + + let error; + if ('label' in data) { + error = validateLabel(data.label); + if (error) throw error; + } + + const fields = [], values = []; + for (const p in data) { + if (p === 'label' || p === 'preserveSecs' || p === 'state') { + fields.push(p + ' = ?'); + values.push(data[p]); + } } values.push(id); diff --git a/src/constants.js b/src/constants.js index 30f61f679..2b468bdad 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,6 +1,6 @@ 'use strict'; -let fs = require('fs'), +const fs = require('fs'), path = require('path'); const CLOUDRON = process.env.BOX_ENV === 'cloudron', diff --git a/src/routes/apps.js b/src/routes/apps.js index 38cf0e54d..1784064d2 100644 --- a/src/routes/apps.js +++ b/src/routes/apps.js @@ -50,6 +50,8 @@ exports = module.exports = { uploadFile, downloadFile, + updateBackup, + getLimits, load @@ -798,6 +800,21 @@ async function listBackups(req, res, next) { next(new HttpSuccess(200, { backups: result })); } +async function updateBackup(req, res, next) { + assert.strictEqual(typeof req.app, 'object'); + assert.strictEqual(typeof req.params.backupId, 'string'); + assert.strictEqual(typeof req.body, 'object'); + + const { label, preserveSecs } = req.body; + if (typeof label !== 'string') return next(new HttpError(400, 'label must be a string')); + if (typeof preserveSecs !== 'number') return next(new HttpError(400, 'preserveSecs must be a number')); + + const [error] = await safe(apps.updateBackup(req.app, req.params.backupId, { label, preserveSecs })); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(200, {})); +} + async function uploadFile(req, res, next) { assert.strictEqual(typeof req.app, 'object'); diff --git a/src/routes/backups.js b/src/routes/backups.js index 50fd21376..a4d492633 100644 --- a/src/routes/backups.js +++ b/src/routes/backups.js @@ -2,12 +2,14 @@ exports = module.exports = { list, + update, startBackup, cleanup, - remount + remount, }; -const AuditSource = require('../auditsource.js'), +const assert = require('assert'), + AuditSource = require('../auditsource.js'), backups = require('../backups.js'), BoxError = require('../boxerror.js'), HttpError = require('connect-lastmile').HttpError, @@ -27,6 +29,20 @@ async function list(req, res, next) { next(new HttpSuccess(200, { backups: result })); } +async function update(req, res, next) { + assert.strictEqual(typeof req.params.backupId, 'string'); + assert.strictEqual(typeof req.body, 'object'); + + const { label, preserveSecs } = req.body; + if (typeof label !== 'string') return next(new HttpError(400, 'label must be a string')); + if (typeof preserveSecs !== 'number') return next(new HttpError(400, 'preserveSecs must be a number')); + + const [error] = await safe(backups.update(req.params.backupId, { label, preserveSecs })); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(200, {})); +} + async function startBackup(req, res, next) { const [error, taskId] = await safe(backups.startBackupTask(AuditSource.fromRequest(req))); if (error) return next(BoxError.toHttpError(error)); diff --git a/src/routes/test/backups-test.js b/src/routes/test/backups-test.js index 992f06896..aba82c76b 100644 --- a/src/routes/test/backups-test.js +++ b/src/routes/test/backups-test.js @@ -7,15 +7,27 @@ const common = require('./common.js'), expect = require('expect.js'), + settings = require('../../settings.js'), superagent = require('superagent'); describe('Backups API', function () { - const { setup, cleanup, serverUrl, owner } = common; + const { setup, cleanup, waitForTask, serverUrl, owner } = common; before(setup); after(cleanup); describe('create', function () { + before(async function () { + await settings.setBackupConfig({ + provider: 'filesystem', + backupFolder: '/tmp/backups', + format: 'tgz', + encryption: null, + retentionPolicy: { keepWithinSecs: 2 * 24 * 60 * 60 }, // 2 days + schedulePattern: '00 00 23 * * *' // every day at 11pm + }); + }); + it('fails due to mising token', async function () { const response = await superagent.post(`${serverUrl}/api/v1/backups/create`) .ok(() => true); @@ -34,6 +46,7 @@ describe('Backups API', function () { .query({ access_token: owner.token }); expect(response.statusCode).to.equal(202); expect(response.body.taskId).to.be.a('string'); + await waitForTask(response.body.taskId); }); }); @@ -42,7 +55,43 @@ describe('Backups API', function () { const response = await superagent.get(`${serverUrl}/api/v1/backups`) .query({ access_token: owner.token }); expect(response.statusCode).to.equal(200); - expect(response.body.backups).to.be.an('array'); + expect(response.body.backups.length).to.be(1); + }); + }); + + describe('update', function () { + let someBackup; + + before(async function () { + const response = await superagent.get(`${serverUrl}/api/v1/backups`) + .query({ access_token: owner.token }); + expect(response.statusCode).to.equal(200); + someBackup = response.body.backups[0]; + console.log(someBackup); + }); + + it('fails for bad param', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/backups/bad_id`) + .query({ access_token: owner.token }) + .send({ preserveSecs: 'not-a-number' }) + .ok(() => true); + expect(response.statusCode).to.equal(400); + }); + + it('fails for unknown backup', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/backups/bad_id`) + .query({ access_token: owner.token }) + .send({ preserveSecs: 30, label: 'NewOrleans' }) + .ok(() => true); + + expect(response.statusCode).to.equal(404); + }); + + it('succeeds', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/backups/${someBackup.id}`) + .query({ access_token: owner.token }) + .send({ preserveSecs: 30, label: 'NewOrleans' }); + expect(response.statusCode).to.equal(400); }); }); }); diff --git a/src/routes/test/common.js b/src/routes/test/common.js index 84c12ba93..bb5baa597 100644 --- a/src/routes/test/common.js +++ b/src/routes/test/common.js @@ -11,6 +11,7 @@ const constants = require('../../constants.js'), settings = require('../../settings.js'), support = require('../../support.js'), superagent = require('superagent'), + tasks = require('../../tasks.js'), tokens = require('../../tokens.js'); exports = module.exports = { @@ -19,6 +20,7 @@ exports = module.exports = { cleanup, clearMailQueue, checkMails, + waitForTask, owner: { id: null, @@ -99,3 +101,15 @@ async function checkMails(number) { expect(mailer._mailQueue.length).to.equal(number); clearMailQueue(); } + +async function waitForTask(taskId) { + // eslint-disable-next-line no-constant-condition + for (let i = 0; i < 10; i++) { + const result = await tasks.get(taskId); + expect(result).to.not.be(null); + if (!result.active) return; + await delay(2000); + console.log(`Waiting for task to ${taskId} finish`); + } + throw new Error(`Task ${taskId} never finished`); +} diff --git a/src/server.js b/src/server.js index d86dcd306..099561faa 100644 --- a/src/server.js +++ b/src/server.js @@ -137,10 +137,11 @@ function initializeExpressSync() { router.post('/api/v1/notifications/:notificationId', json, token, authorizeAdmin, routes.notifications.load, routes.notifications.update); // backup routes - router.get ('/api/v1/backups', token, authorizeAdmin, routes.backups.list); - router.post('/api/v1/backups/create', token, authorizeAdmin, routes.backups.startBackup); - router.post('/api/v1/backups/cleanup', json, token, authorizeAdmin, routes.backups.cleanup); - router.post('/api/v1/backups/remount', json, token, authorizeAdmin, routes.backups.remount); + router.get ('/api/v1/backups', token, authorizeAdmin, routes.backups.list); + router.post('/api/v1/backups/create', token, authorizeAdmin, routes.backups.startBackup); + router.post('/api/v1/backups/cleanup', json, token, authorizeAdmin, routes.backups.cleanup); + router.post('/api/v1/backups/remount', json, token, authorizeAdmin, routes.backups.remount); + router.post('/api/v1/backups/:backupId', json, token, authorizeAdmin, routes.backups.update); // config route (for dashboard). can return some private configuration unlike status router.get ('/api/v1/config', token, routes.cloudron.getConfig); @@ -232,6 +233,7 @@ function initializeExpressSync() { router.post('/api/v1/apps/:id/export', json, token, routes.apps.load, authorizeOperator, routes.apps.exportApp); router.post('/api/v1/apps/:id/backup', json, token, routes.apps.load, authorizeOperator, routes.apps.backup); router.get ('/api/v1/apps/:id/backups', token, routes.apps.load, authorizeOperator, routes.apps.listBackups); + router.post('/api/v1/apps/:id/backups/:backupId', json, token, routes.apps.load, authorizeOperator, routes.apps.updateBackup); router.post('/api/v1/apps/:id/start', json, token, routes.apps.load, authorizeOperator, routes.apps.start); router.post('/api/v1/apps/:id/stop', json, token, routes.apps.load, authorizeOperator, routes.apps.stop); router.post('/api/v1/apps/:id/restart', json, token, routes.apps.load, authorizeOperator, routes.apps.restart); diff --git a/src/test/backups-test.js b/src/test/backups-test.js index 0a7d1c6fb..4683ba1d6 100644 --- a/src/test/backups-test.js +++ b/src/test/backups-test.js @@ -28,7 +28,8 @@ describe('backups', function () { dependsOn: [ 'dep1' ], manifest: null, format: 'tgz', - preserveSecs: 0 + preserveSecs: 0, + label: '' }; const appBackup = { @@ -41,7 +42,8 @@ describe('backups', function () { dependsOn: [ ], manifest: { foo: 'bar' }, format: 'tgz', - preserveSecs: 0 + preserveSecs: 0, + label: '' }; it('add succeeds', async function () { @@ -71,6 +73,13 @@ describe('backups', function () { expect(results[0]).to.eql(boxBackup); }); + it('update succeeds', async function () { + await backups.update(boxBackup.id, { label: 'DuMonde', preserveSecs: 30 }); + const result = await backups.get(boxBackup.id); + expect(result.label).to.eql('DuMonde'); + expect(result.preserveSecs).to.eql(30); + }); + it('delete succeeds', async function () { await backups.del(boxBackup.id); const result = await backups.get(boxBackup.id);