Files
cloudron-box/src/backupcleaner.js

307 lines
12 KiB
JavaScript
Raw Normal View History

2021-07-14 11:07:19 -07:00
'use strict';
exports = module.exports = {
run,
_applyBackupRetentionPolicy: applyBackupRetentionPolicy
};
const apps = require('./apps.js'),
assert = require('assert'),
backups = require('./backups.js'),
constants = require('./constants.js'),
debug = require('debug')('box:backupcleaner'),
moment = require('moment'),
path = require('path'),
paths = require('./paths.js'),
safe = require('safetydance'),
settings = require('./settings.js'),
storage = require('./storage.js'),
util = require('util'),
_ = require('underscore');
2021-07-14 19:03:12 -07:00
function applyBackupRetentionPolicy(allBackups, policy, referencedBackupIds) {
assert(Array.isArray(allBackups));
2021-07-14 11:07:19 -07:00
assert.strictEqual(typeof policy, 'object');
assert(Array.isArray(referencedBackupIds));
const now = new Date();
2021-07-14 19:03:12 -07:00
for (const backup of allBackups) {
2021-07-14 11:07:19 -07:00
if (backup.state === backups.BACKUP_STATE_ERROR) {
backup.discardReason = 'error';
} else if (backup.state === backups.BACKUP_STATE_CREATING) {
if ((now - backup.creationTime) < 48*60*60*1000) backup.keepReason = 'creating';
else backup.discardReason = 'creating-too-long';
} else if (referencedBackupIds.includes(backup.id)) {
backup.keepReason = 'reference';
} else if ((now - backup.creationTime) < (backup.preserveSecs * 1000)) {
backup.keepReason = 'preserveSecs';
} else if ((now - backup.creationTime < policy.keepWithinSecs * 1000) || policy.keepWithinSecs < 0) {
backup.keepReason = 'keepWithinSecs';
}
}
const KEEP_FORMATS = {
keepDaily: 'Y-M-D',
keepWeekly: 'Y-W',
keepMonthly: 'Y-M',
keepYearly: 'Y'
};
for (const format of [ 'keepDaily', 'keepWeekly', 'keepMonthly', 'keepYearly' ]) {
if (!(format in policy)) continue;
const n = policy[format]; // we want to keep "n" backups of format
if (!n) continue; // disabled rule
let lastPeriod = null, keptSoFar = 0;
2021-07-14 19:03:12 -07:00
for (const backup of allBackups) {
2021-07-14 11:07:19 -07:00
if (backup.discardReason) continue; // already discarded for some reason
if (backup.keepReason && backup.keepReason !== 'reference') continue; // kept for some other reason
const period = moment(backup.creationTime).format(KEEP_FORMATS[format]);
if (period === lastPeriod) continue; // already kept for this period
lastPeriod = period;
backup.keepReason = backup.keepReason ? `${backup.keepReason}+${format}` : format;
if (++keptSoFar === n) break;
}
}
if (policy.keepLatest) {
2021-07-14 19:03:12 -07:00
let latestNormalBackup = allBackups.find(b => b.state === backups.BACKUP_STATE_NORMAL);
2021-07-14 11:07:19 -07:00
if (latestNormalBackup && !latestNormalBackup.keepReason) latestNormalBackup.keepReason = 'latest';
}
2021-07-14 19:03:12 -07:00
for (const backup of allBackups) {
2021-07-14 11:07:19 -07:00
debug(`applyBackupRetentionPolicy: ${backup.id} ${backup.type} ${backup.keepReason || backup.discardReason || 'unprocessed'}`);
}
}
async function cleanupBackup(backupConfig, backup, progressCallback) {
assert.strictEqual(typeof backupConfig, 'object');
assert.strictEqual(typeof backup, 'object');
assert.strictEqual(typeof progressCallback, 'function');
const backupFilePath = storage.getBackupFilePath(backupConfig, backup.id, backup.format);
return new Promise((resolve) => {
function done(error) {
if (error) {
debug('cleanupBackup: error removing backup %j : %s', backup, error.message);
return resolve();
}
// prune empty directory if possible
storage.api(backupConfig.provider).remove(backupConfig, path.dirname(backupFilePath), async function (error) {
if (error) debug('cleanupBackup: unable to prune backup directory %s : %s', path.dirname(backupFilePath), error.message);
const [delError] = await safe(backups.del(backup.id));
if (delError) debug('cleanupBackup: error removing from database', delError);
else debug('cleanupBackup: removed %s', backup.id);
resolve();
});
}
if (backup.format ==='tgz') {
progressCallback({ message: `${backup.id}: Removing ${backupFilePath}`});
storage.api(backupConfig.provider).remove(backupConfig, backupFilePath, done);
} else {
const events = storage.api(backupConfig.provider).removeDir(backupConfig, backupFilePath);
events.on('progress', (message) => progressCallback({ message: `${backup.id}: ${message}` }));
events.on('done', done);
}
});
}
2021-08-20 09:19:44 -07:00
async function cleanupAppBackups(backupConfig, referencedAppBackupIds, progressCallback) {
2021-07-14 11:07:19 -07:00
assert.strictEqual(typeof backupConfig, 'object');
assert(Array.isArray(referencedAppBackupIds));
assert.strictEqual(typeof progressCallback, 'function');
let removedAppBackupIds = [];
2021-08-20 09:19:44 -07:00
const allApps = await apps.list();
const allAppIds = allApps.map(a => a.id);
2021-07-14 11:07:19 -07:00
2021-08-20 09:19:44 -07:00
const appBackups = await backups.getByTypePaged(backups.BACKUP_TYPE_APP, 1, 1000);
2021-07-14 11:07:19 -07:00
2021-08-20 09:19:44 -07:00
// collate the backups by app id. note that the app could already have been uninstalled
let appBackupsById = {};
for (const appBackup of appBackups) {
if (!appBackupsById[appBackup.identifier]) appBackupsById[appBackup.identifier] = [];
appBackupsById[appBackup.identifier].push(appBackup);
}
2021-07-14 11:07:19 -07:00
2021-08-20 09:19:44 -07:00
// apply backup policy per app. keep latest backup only for existing apps
let appBackupsToRemove = [];
for (const appId of Object.keys(appBackupsById)) {
applyBackupRetentionPolicy(appBackupsById[appId], _.extend({ keepLatest: allAppIds.includes(appId) }, backupConfig.retentionPolicy), referencedAppBackupIds);
appBackupsToRemove = appBackupsToRemove.concat(appBackupsById[appId].filter(b => !b.keepReason));
}
2021-07-14 11:07:19 -07:00
2021-08-20 09:19:44 -07:00
for (const appBackup of appBackupsToRemove) {
await progressCallback({ message: `Removing app backup (${appBackup.identifier}): ${appBackup.id}`});
removedAppBackupIds.push(appBackup.id);
await cleanupBackup(backupConfig, appBackup, progressCallback); // never errors
}
2021-07-14 11:07:19 -07:00
2021-08-20 09:19:44 -07:00
debug('cleanupAppBackups: done');
2021-07-14 11:07:19 -07:00
2021-08-20 09:19:44 -07:00
return removedAppBackupIds;
2021-07-14 11:07:19 -07:00
}
async function cleanupMailBackups(backupConfig, referencedAppBackupIds, progressCallback) {
assert.strictEqual(typeof backupConfig, 'object');
assert(Array.isArray(referencedAppBackupIds));
assert.strictEqual(typeof progressCallback, 'function');
let removedMailBackupIds = [];
const mailBackups = await backups.getByTypePaged(backups.BACKUP_TYPE_MAIL, 1, 1000);
applyBackupRetentionPolicy(mailBackups, _.extend({ keepLatest: true }, backupConfig.retentionPolicy), referencedAppBackupIds);
for (const mailBackup of mailBackups) {
if (mailBackup.keepReason) continue;
await progressCallback({ message: `Removing mail backup ${mailBackup.id}`});
removedMailBackupIds.push(mailBackup.id);
await cleanupBackup(backupConfig, mailBackup, progressCallback); // never errors
}
debug('cleanupMailBackups: done');
return removedMailBackupIds;
}
2021-07-14 11:07:19 -07:00
async function cleanupBoxBackups(backupConfig, progressCallback) {
assert.strictEqual(typeof backupConfig, 'object');
assert.strictEqual(typeof progressCallback, 'function');
let referencedAppBackupIds = [], removedBoxBackupIds = [];
const boxBackups = await backups.getByTypePaged(backups.BACKUP_TYPE_BOX, 1, 1000);
applyBackupRetentionPolicy(boxBackups, _.extend({ keepLatest: true }, backupConfig.retentionPolicy), [] /* references */);
for (const boxBackup of boxBackups) {
if (boxBackup.keepReason) {
referencedAppBackupIds = referencedAppBackupIds.concat(boxBackup.dependsOn);
continue;
}
2021-08-20 09:19:44 -07:00
await progressCallback({ message: `Removing box backup ${boxBackup.id}`});
2021-07-14 11:07:19 -07:00
removedBoxBackupIds.push(boxBackup.id);
await cleanupBackup(backupConfig, boxBackup, progressCallback);
}
debug('cleanupBoxBackups: done');
return { removedBoxBackupIds, referencedAppBackupIds };
}
async function cleanupMissingBackups(backupConfig, progressCallback) {
assert.strictEqual(typeof backupConfig, 'object');
assert.strictEqual(typeof progressCallback, 'function');
const perPage = 1000;
let missingBackupIds = [];
const backupExists = util.promisify(storage.api(backupConfig.provider).exists);
if (constants.TEST) return missingBackupIds;
2021-09-26 21:59:48 -07:00
let page = 1, result = [];
do {
2021-07-14 11:07:19 -07:00
result = await backups.list(page, perPage);
for (const backup of result) {
let backupFilePath = storage.getBackupFilePath(backupConfig, backup.id, backup.format);
if (backup.format === 'rsync') backupFilePath = backupFilePath + '/'; // add trailing slash to indicate directory
const [existsError, exists] = await safe(backupExists(backupConfig, backupFilePath));
if (existsError || exists) continue;
2021-08-20 09:19:44 -07:00
await progressCallback({ message: `Removing missing backup ${backup.id}`});
2021-07-14 11:07:19 -07:00
const [delError] = await safe(backups.del(backup.id));
if (delError) debug(`cleanupBackup: error removing ${backup.id} from database`, delError);
missingBackupIds.push(backup.id);
}
2021-09-26 21:59:48 -07:00
++ page;
} while (result.length === perPage);
debug('cleanupMissingBackups: done');
2021-07-14 11:07:19 -07:00
return missingBackupIds;
}
// removes the snapshots of apps that have been uninstalled
2021-08-20 09:19:44 -07:00
async function cleanupSnapshots(backupConfig) {
2021-07-14 11:07:19 -07:00
assert.strictEqual(typeof backupConfig, 'object');
2021-08-20 09:19:44 -07:00
const contents = safe.fs.readFileSync(paths.SNAPSHOT_INFO_FILE, 'utf8');
const info = safe.JSON.parse(contents);
if (!info) return;
2021-07-14 11:07:19 -07:00
delete info.box;
2021-08-20 09:19:44 -07:00
for (const appId of Object.keys(info)) {
2021-09-26 21:59:48 -07:00
const app = await apps.get(appId);
2021-08-20 09:19:44 -07:00
if (app) continue; // app is still installed
await new Promise((resolve) => {
2021-09-26 21:59:48 -07:00
async function done(/* ignoredError */) {
2021-07-14 11:07:19 -07:00
safe.fs.unlinkSync(path.join(paths.BACKUP_INFO_DIR, `${appId}.sync.cache`));
safe.fs.unlinkSync(path.join(paths.BACKUP_INFO_DIR, `${appId}.sync.cache.new`));
2021-09-26 21:59:48 -07:00
await safe(backups.setSnapshotInfo(appId, null /* info */), { debug });
debug(`cleanupSnapshots: cleaned up snapshot of app ${appId}`);
2021-07-14 11:07:19 -07:00
2021-09-26 21:59:48 -07:00
resolve();
2021-07-14 11:07:19 -07:00
}
if (info[appId].format ==='tgz') {
storage.api(backupConfig.provider).remove(backupConfig, storage.getBackupFilePath(backupConfig, `snapshot/app_${appId}`, info[appId].format), done);
} else {
2021-09-26 21:59:48 -07:00
const events = storage.api(backupConfig.provider).removeDir(backupConfig, storage.getBackupFilePath(backupConfig, `snapshot/app_${appId}`, info[appId].format));
2021-07-14 11:07:19 -07:00
events.on('progress', function (detail) { debug(`cleanupSnapshots: ${detail}`); });
events.on('done', done);
}
});
2021-08-20 09:19:44 -07:00
}
2021-07-14 11:07:19 -07:00
2021-08-20 09:19:44 -07:00
debug('cleanupSnapshots: done');
2021-07-14 11:07:19 -07:00
}
2021-08-20 09:19:44 -07:00
async function run(progressCallback) {
2021-07-14 11:07:19 -07:00
assert.strictEqual(typeof progressCallback, 'function');
2021-08-20 09:19:44 -07:00
const backupConfig = await settings.getBackupConfig();
2021-08-19 13:24:38 -07:00
2021-08-20 09:19:44 -07:00
if (backupConfig.retentionPolicy.keepWithinSecs < 0) {
debug('cleanup: keeping all backups');
return {};
}
2021-07-14 11:07:19 -07:00
2021-08-20 09:19:44 -07:00
await progressCallback({ percent: 10, message: 'Cleaning box backups' });
const { removedBoxBackupIds, referencedAppBackupIds } = await cleanupBoxBackups(backupConfig, progressCallback);
2021-07-14 11:07:19 -07:00
await progressCallback({ percent: 20, message: 'Cleaning mail backups' });
const removedMailBackupIds = await cleanupMailBackups(backupConfig, referencedAppBackupIds, progressCallback);
2021-08-20 09:19:44 -07:00
await progressCallback({ percent: 40, message: 'Cleaning app backups' });
const removedAppBackupIds = await cleanupAppBackups(backupConfig, referencedAppBackupIds, progressCallback);
2021-07-14 11:07:19 -07:00
2021-08-20 09:19:44 -07:00
await progressCallback({ percent: 70, message: 'Cleaning missing backups' });
const missingBackupIds = await cleanupMissingBackups(backupConfig, progressCallback);
2021-07-14 11:07:19 -07:00
2021-08-20 09:19:44 -07:00
await progressCallback({ percent: 90, message: 'Cleaning snapshots' });
await cleanupSnapshots(backupConfig);
2021-07-14 11:07:19 -07:00
return { removedBoxBackupIds, removedMailBackupIds, removedAppBackupIds, missingBackupIds };
2021-07-14 11:07:19 -07:00
}