Files
cloudron-box/src/updater.js

241 lines
11 KiB
JavaScript
Raw Normal View History

2018-07-31 11:35:23 -07:00
'use strict';
exports = module.exports = {
setAutoupdatePattern,
getAutoupdatePattern,
2021-01-31 20:46:55 -08:00
updateToLatest,
2023-08-12 19:28:07 +05:30
update,
notifyUpdate
2018-07-31 11:35:23 -07:00
};
2021-08-31 11:16:58 -07:00
const apps = require('./apps.js'),
assert = require('assert'),
2023-08-12 19:28:07 +05:30
AuditSource = require('./auditsource.js'),
2019-10-23 09:39:26 -07:00
BoxError = require('./boxerror.js'),
2023-08-04 11:24:28 +05:30
backups = require('./backups.js'),
2021-08-31 11:16:58 -07:00
backuptask = require('./backuptask.js'),
constants = require('./constants.js'),
cron = require('./cron.js'),
{ CronTime } = require('cron'),
2018-08-01 15:38:40 -07:00
crypto = require('crypto'),
2018-07-31 11:35:23 -07:00
debug = require('debug')('box:updater'),
df = require('./df.js'),
eventlog = require('./eventlog.js'),
fs = require('fs'),
locker = require('./locker.js'),
2018-08-01 15:38:40 -07:00
os = require('os'),
2018-07-31 11:35:23 -07:00
path = require('path'),
2018-08-01 15:38:40 -07:00
paths = require('./paths.js'),
2021-08-31 13:12:14 -07:00
promiseRetry = require('./promise-retry.js'),
2018-08-01 15:38:40 -07:00
safe = require('safetydance'),
semver = require('semver'),
2021-02-01 14:07:23 -08:00
settings = require('./settings.js'),
2018-07-31 11:35:23 -07:00
shell = require('./shell.js'),
tasks = require('./tasks.js'),
updateChecker = require('./updatechecker.js');
2018-07-31 11:35:23 -07:00
2018-08-01 15:38:40 -07:00
const RELEASES_PUBLIC_KEY = path.join(__dirname, 'releases.gpg');
2018-07-31 11:35:23 -07:00
const UPDATE_CMD = path.join(__dirname, 'scripts/update.sh');
async function setAutoupdatePattern(pattern) {
assert.strictEqual(typeof pattern, 'string');
if (pattern !== constants.AUTOUPDATE_PATTERN_NEVER) { // check if pattern is valid
const job = safe.safeCall(function () { return new CronTime(pattern); });
if (!job) throw new BoxError(BoxError.BAD_FIELD, 'Invalid pattern');
}
await settings.set(settings.AUTOUPDATE_PATTERN_KEY, pattern);
await cron.handleAutoupdatePatternChanged(pattern);
}
async function getAutoupdatePattern() {
const pattern = await settings.get(settings.AUTOUPDATE_PATTERN_KEY);
return pattern || cron.DEFAULT_AUTOUPDATE_PATTERN;
}
2021-08-31 13:12:14 -07:00
async function downloadUrl(url, file) {
2018-08-01 15:38:40 -07:00
assert.strictEqual(typeof file, 'string');
// do not assert since it comes from the appstore
2021-08-31 13:12:14 -07:00
if (typeof url !== 'string') throw new BoxError(BoxError.EXTERNAL_ERROR, `url cannot be download to ${file} as it is not a string`);
2018-08-01 15:38:40 -07:00
safe.fs.unlinkSync(file);
2021-12-07 11:18:26 -08:00
await promiseRetry({ times: 10, interval: 5000, debug }, async function () {
2024-02-21 13:09:59 +01:00
debug(`downloadUrl: downloading ${url} to ${file}`);
2024-02-21 19:40:27 +01:00
const [error] = await safe(shell.exec('downloadUrl', `curl -s --fail ${url} -o ${file}`, {}));
2021-08-31 13:12:14 -07:00
if (error) throw new BoxError(BoxError.NETWORK_ERROR, `Failed to download ${url}: ${error.message}`);
2024-02-21 13:09:59 +01:00
debug('downloadUrl: done');
2021-08-31 13:12:14 -07:00
});
2018-08-01 15:38:40 -07:00
}
2021-08-31 13:12:14 -07:00
async function gpgVerify(file, sig) {
assert.strictEqual(typeof file, 'string');
assert.strictEqual(typeof sig, 'string');
2018-08-01 15:38:40 -07:00
const cmd = `/usr/bin/gpg --status-fd 1 --no-default-keyring --keyring ${RELEASES_PUBLIC_KEY} --verify ${sig} ${file}`;
debug(`gpgVerify: ${cmd}`);
2024-02-21 19:40:27 +01:00
const [error, stdout] = await safe(shell.exec('gpgVerify', cmd, {}));
2021-08-31 13:12:14 -07:00
if (error) {
debug(`gpgVerify: command failed. error: ${error}\n stdout: ${error.stdout}\n stderr: ${error.stderr}`);
throw new BoxError(BoxError.NOT_SIGNED, `The signature in ${path.basename(sig)} could not be verified (command failed)`);
}
2018-08-01 15:38:40 -07:00
2021-08-31 13:12:14 -07:00
if (stdout.indexOf('[GNUPG:] VALIDSIG 0EADB19CDDA23CD0FE71E3470A372F8703C493CC') !== -1) return; // success
2018-08-01 15:38:40 -07:00
2021-08-31 13:12:14 -07:00
debug(`gpgVerify: verification of ${sig} failed: ${stdout}\n`);
2018-08-01 15:38:40 -07:00
2021-08-31 13:12:14 -07:00
throw new BoxError(BoxError.NOT_SIGNED, `The signature in ${path.basename(sig)} could not be verified (bad sig)`);
2018-08-01 15:38:40 -07:00
}
2021-08-31 13:12:14 -07:00
async function extractTarball(tarball, dir) {
assert.strictEqual(typeof tarball, 'string');
assert.strictEqual(typeof dir, 'string');
2024-02-21 13:09:59 +01:00
debug(`extractTarball: extracting ${tarball} to ${dir}`);
2018-08-01 15:38:40 -07:00
2024-02-21 19:40:27 +01:00
const [error] = await safe(shell.exec('extractTarball', `tar -zxf ${tarball} -C ${dir}`, {}));
2021-08-31 13:12:14 -07:00
if (error) throw new BoxError(BoxError.FS_ERROR, `Failed to extract release package: ${error.message}`);
safe.fs.unlinkSync(tarball);
2018-08-01 15:38:40 -07:00
2024-02-21 13:09:59 +01:00
debug('extractTarball: extracted');
2018-08-01 15:38:40 -07:00
}
2021-08-31 13:12:14 -07:00
async function verifyUpdateInfo(versionsFile, updateInfo) {
2018-08-01 15:38:40 -07:00
assert.strictEqual(typeof versionsFile, 'string');
assert.strictEqual(typeof updateInfo, 'object');
const releases = safe.JSON.parse(safe.fs.readFileSync(versionsFile, 'utf8')) || {};
2021-08-31 13:12:14 -07:00
if (!releases[constants.VERSION]) throw new BoxError(BoxError.EXTERNAL_ERROR, `No version info for ${constants.VERSION}`);
if (!releases[constants.VERSION].next) throw new BoxError(BoxError.EXTERNAL_ERROR, `No next version info for ${constants.VERSION}`);
const nextVersion = releases[constants.VERSION].next;
2021-08-31 13:12:14 -07:00
if (typeof releases[nextVersion] !== 'object' || !releases[nextVersion]) throw new BoxError(BoxError.EXTERNAL_ERROR, 'No next version info');
if (releases[nextVersion].sourceTarballUrl !== updateInfo.sourceTarballUrl) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Version info mismatch');
2018-08-01 15:38:40 -07:00
}
2021-08-31 13:12:14 -07:00
async function downloadAndVerifyRelease(updateInfo) {
2018-08-01 15:38:40 -07:00
assert.strictEqual(typeof updateInfo, 'object');
2021-08-31 13:12:14 -07:00
await safe(shell.exec('cleanupOldArtifacts', `rm -rf ${path.join(os.tmpdir(), 'box-*')}`, { shell: '/bin/bash' }), { debug }); // remove any old artifacts
2021-08-31 13:12:14 -07:00
await downloadUrl(updateInfo.boxVersionsUrl, `${paths.UPDATE_DIR}/versions.json`);
await downloadUrl(updateInfo.boxVersionsSigUrl, `${paths.UPDATE_DIR}/versions.json.sig`);
await gpgVerify(`${paths.UPDATE_DIR}/versions.json`, `${paths.UPDATE_DIR}/versions.json.sig`);
await verifyUpdateInfo(`${paths.UPDATE_DIR}/versions.json`, updateInfo);
await downloadUrl(updateInfo.sourceTarballUrl, `${paths.UPDATE_DIR}/box.tar.gz`);
await downloadUrl(updateInfo.sourceTarballSigUrl, `${paths.UPDATE_DIR}/box.tar.gz.sig`);
await gpgVerify(`${paths.UPDATE_DIR}/box.tar.gz`, `${paths.UPDATE_DIR}/box.tar.gz.sig`);
const newBoxSource = path.join(os.tmpdir(), 'box-' + crypto.randomBytes(4).readUInt32LE(0));
2021-08-31 13:12:14 -07:00
const [mkdirError] = await safe(fs.promises.mkdir(newBoxSource, { recursive: true }));
if (mkdirError) throw new BoxError(BoxError.FS_ERROR, `Failed to create directory ${newBoxSource}: ${mkdirError.message}`);
await extractTarball(`${paths.UPDATE_DIR}/box.tar.gz`, newBoxSource);
return { file: newBoxSource };
2018-08-01 15:38:40 -07:00
}
2021-08-31 13:12:14 -07:00
async function checkFreeDiskSpace(neededSpace) {
assert.strictEqual(typeof neededSpace, 'number');
// can probably be a bit more aggressive here since a new update can bring in new docker images
2021-08-31 13:12:14 -07:00
const [error, diskUsage] = await safe(df.file('/'));
if (error) throw new BoxError(BoxError.FS_ERROR, error);
2019-08-12 21:47:22 -07:00
2023-01-30 12:54:25 +01:00
if (diskUsage.available < neededSpace) throw new BoxError(BoxError.FS_ERROR, `Not enough disk space. Updates require at least 2GB of free space. Available: ${df.prettyBytes(diskUsage.available)}`);
}
2021-08-31 13:12:14 -07:00
async function update(boxUpdateInfo, options, progressCallback) {
2018-08-01 15:38:40 -07:00
assert(boxUpdateInfo && typeof boxUpdateInfo === 'object');
assert(options && typeof options === 'object');
assert.strictEqual(typeof progressCallback, 'function');
2018-08-01 15:38:40 -07:00
progressCallback({ percent: 1, message: 'Checking disk space' });
2018-08-01 15:38:40 -07:00
2022-02-18 09:56:35 -08:00
await checkFreeDiskSpace(2*1024*1024*1024);
2018-08-01 15:38:40 -07:00
2021-08-31 13:12:14 -07:00
progressCallback({ percent: 5, message: 'Downloading and verifying release' });
2021-08-31 13:12:14 -07:00
const packageInfo = await downloadAndVerifyRelease(boxUpdateInfo);
2018-08-01 15:38:40 -07:00
2021-08-31 13:12:14 -07:00
if (!options.skipBackup) {
progressCallback({ percent: 10, message: 'Backing up' });
2018-08-01 15:38:40 -07:00
await backuptask.fullBackup({ preserveSecs: 3*7*24*60*60 }, (progress) => progressCallback({ percent: 10+progress.percent*70/100, message: progress.message }));
2022-02-18 09:56:35 -08:00
await checkFreeDiskSpace(2*1024*1024*1024); // check again in case backup is in same disk
2021-08-31 13:12:14 -07:00
}
2018-08-01 15:38:40 -07:00
debug(`Updating box with ${boxUpdateInfo.sourceTarballUrl}`);
2021-08-31 13:12:14 -07:00
progressCallback({ percent: 70, message: 'Installing update' });
await shell.promises.sudo('update', [ UPDATE_CMD, packageInfo.file, process.stdout.logFile ], {}); // run installer.sh from new box code as a separate service
2021-08-31 13:12:14 -07:00
// Do not add any code here. The installer script will stop the box code any instant
2018-08-01 15:38:40 -07:00
}
2018-07-31 11:35:23 -07:00
async function checkUpdateRequirements(boxUpdateInfo) {
assert.strictEqual(typeof boxUpdateInfo, 'object');
2021-08-20 09:19:44 -07:00
const result = await apps.list();
2024-05-13 17:02:20 +02:00
for (const app of result) {
2021-08-20 09:19:44 -07:00
const maxBoxVersion = app.manifest.maxBoxVersion;
if (semver.valid(maxBoxVersion) && semver.gt(boxUpdateInfo.version, maxBoxVersion)) {
throw new BoxError(BoxError.BAD_STATE, `Cannot update to v${boxUpdateInfo.version} because ${app.fqdn} has a maxBoxVersion of ${maxBoxVersion}`);
}
2021-08-20 09:19:44 -07:00
}
}
2021-08-20 09:19:44 -07:00
async function updateToLatest(options, auditSource) {
assert.strictEqual(typeof options, 'object');
2018-07-31 11:35:23 -07:00
assert.strictEqual(typeof auditSource, 'object');
2021-08-20 09:19:44 -07:00
const boxUpdateInfo = updateChecker.getUpdateInfo().box;
if (!boxUpdateInfo) throw new BoxError(BoxError.NOT_FOUND, 'No update available');
if (!boxUpdateInfo.sourceTarballUrl) throw new BoxError(BoxError.BAD_STATE, 'No automatic update available');
if (semver.gte(constants.VERSION, boxUpdateInfo.version)) throw new BoxError(BoxError.NOT_FOUND, 'No update available'); // can happen after update completed or hotfix
2018-07-31 11:35:23 -07:00
await checkUpdateRequirements(boxUpdateInfo);
2018-12-04 14:04:43 -08:00
2021-08-20 09:19:44 -07:00
const error = locker.lock(locker.OP_BOX_UPDATE);
if (error) throw new BoxError(BoxError.BAD_STATE, `Cannot update now: ${error.message}`);
2023-08-04 11:24:28 +05:30
const backupConfig = await backups.getConfig();
const memoryLimit = backupConfig.limits?.memoryLimit ? Math.max(backupConfig.limits.memoryLimit/1024/1024, 400) : 400;
2023-08-04 11:24:28 +05:30
const taskId = await tasks.add(tasks.TASK_UPDATE, [ boxUpdateInfo, options ]);
2022-02-24 20:04:46 -08:00
await eventlog.add(eventlog.ACTION_UPDATE, auditSource, { taskId, boxUpdateInfo });
2022-02-24 20:04:46 -08:00
tasks.startTask(taskId, { timeout: 20 * 60 * 60 * 1000 /* 20 hours */, nice: 15, memoryLimit }, async (error) => {
2021-08-20 09:19:44 -07:00
locker.unlock(locker.OP_BOX_UPDATE);
2021-02-01 14:07:23 -08:00
debug('Update failed with error. %o', error);
2021-07-12 23:35:30 -07:00
2021-08-20 09:19:44 -07:00
const timedOut = error.code === tasks.ETIMEOUT;
2022-02-24 20:04:46 -08:00
await safe(eventlog.add(eventlog.ACTION_UPDATE_FINISH, auditSource, { taskId, errorMessage: error.message, timedOut }));
});
2021-08-20 09:19:44 -07:00
return taskId;
2018-07-31 11:35:23 -07:00
}
2023-08-12 19:28:07 +05:30
async function notifyUpdate() {
const version = safe.fs.readFileSync(paths.VERSION_FILE, 'utf8');
if (version === constants.VERSION) return;
if (!version) {
await eventlog.add(eventlog.ACTION_INSTALL_FINISH, AuditSource.CRON, { version: constants.VERSION });
} else {
await eventlog.add(eventlog.ACTION_UPDATE_FINISH, AuditSource.CRON, { errorMessage: '', oldVersion: version || 'dev', newVersion: constants.VERSION });
const [error] = await safe(tasks.setCompletedByType(tasks.TASK_UPDATE, { error: null }));
if (error && error.reason !== BoxError.NOT_FOUND) throw error; // when hotfixing, task may not exist
}
safe.fs.writeFileSync(paths.VERSION_FILE, constants.VERSION, 'utf8');
}