5157789774
previously, we had a singleton 'main' flag to indicate a site can be used for updates. with this new approach, we can get rid of the 'primary' concept. each site can be used for updates or not.
375 lines
16 KiB
JavaScript
375 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
exports = module.exports = {
|
|
setAutoupdatePattern,
|
|
getAutoupdatePattern,
|
|
|
|
startBoxUpdateTask,
|
|
updateBox,
|
|
|
|
autoUpdate,
|
|
|
|
notifyBoxUpdate,
|
|
|
|
checkForUpdates,
|
|
checkAppUpdate,
|
|
checkBoxUpdate,
|
|
|
|
getBoxUpdate,
|
|
};
|
|
|
|
const apps = require('./apps.js'),
|
|
appstore = require('./appstore.js'),
|
|
assert = require('node:assert'),
|
|
AuditSource = require('./auditsource.js'),
|
|
BoxError = require('./boxerror.js'),
|
|
backupSites = require('./backupsites.js'),
|
|
backuptask = require('./backuptask.js'),
|
|
constants = require('./constants.js'),
|
|
cron = require('./cron.js'),
|
|
{ CronTime } = require('cron'),
|
|
crypto = require('node:crypto'),
|
|
debug = require('debug')('box:updater'),
|
|
df = require('./df.js'),
|
|
eventlog = require('./eventlog.js'),
|
|
fs = require('node:fs'),
|
|
locks = require('./locks.js'),
|
|
notifications = require('./notifications.js'),
|
|
os = require('node:os'),
|
|
path = require('node:path'),
|
|
paths = require('./paths.js'),
|
|
promiseRetry = require('./promise-retry.js'),
|
|
safe = require('safetydance'),
|
|
semver = require('semver'),
|
|
settings = require('./settings.js'),
|
|
shell = require('./shell.js')('updater'),
|
|
tasks = require('./tasks.js');
|
|
|
|
const RELEASES_PUBLIC_KEY = path.join(__dirname, 'releases.gpg');
|
|
const UPDATE_CMD = path.join(__dirname, 'scripts/update.sh');
|
|
|
|
async function setAutoupdatePattern(pattern) {
|
|
assert.strictEqual(typeof pattern, 'string');
|
|
|
|
if (pattern !== constants.CRON_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;
|
|
}
|
|
|
|
async function downloadBoxUrl(url, file) {
|
|
assert.strictEqual(typeof file, 'string');
|
|
|
|
// do not assert since it comes from the appstore
|
|
if (typeof url !== 'string') throw new BoxError(BoxError.EXTERNAL_ERROR, `url cannot be download to ${file} as it is not a string`);
|
|
|
|
safe.fs.unlinkSync(file);
|
|
|
|
await promiseRetry({ times: 10, interval: 5000, debug }, async function () {
|
|
debug(`downloadBoxUrl: downloading ${url} to ${file}`);
|
|
const [error] = await safe(shell.spawn('curl', ['-s', '--fail', url, '-o', file], {}));
|
|
if (error) throw new BoxError(BoxError.NETWORK_ERROR, `Failed to download ${url}: ${error.message}`);
|
|
debug('downloadBoxUrl: done');
|
|
});
|
|
}
|
|
|
|
async function gpgVerifyBoxTarball(file, sig) {
|
|
assert.strictEqual(typeof file, 'string');
|
|
assert.strictEqual(typeof sig, 'string');
|
|
|
|
const [error, stdout] = await safe(shell.spawn('/usr/bin/gpg', ['--status-fd', '1', '--no-default-keyring', '--keyring', RELEASES_PUBLIC_KEY, '--verify', sig, file], { encoding: 'utf8' }));
|
|
if (error) {
|
|
debug(`gpgVerifyBoxTarball: 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)`);
|
|
}
|
|
|
|
if (stdout.indexOf('[GNUPG:] VALIDSIG 0EADB19CDDA23CD0FE71E3470A372F8703C493CC') !== -1) return; // success
|
|
|
|
debug(`gpgVerifyBoxTarball: verification of ${sig} failed: ${stdout}\n`);
|
|
|
|
throw new BoxError(BoxError.NOT_SIGNED, `The signature in ${path.basename(sig)} could not be verified (bad sig)`);
|
|
}
|
|
|
|
async function extractBoxTarball(tarball, dir) {
|
|
assert.strictEqual(typeof tarball, 'string');
|
|
assert.strictEqual(typeof dir, 'string');
|
|
|
|
debug(`extractBoxTarball: extracting ${tarball} to ${dir}`);
|
|
|
|
const [error] = await safe(shell.spawn('tar', ['-zxf', tarball, '-C', dir], {}));
|
|
if (error) throw new BoxError(BoxError.FS_ERROR, `Failed to extract release package: ${error.message}`);
|
|
safe.fs.unlinkSync(tarball);
|
|
|
|
debug('extractBoxTarball: extracted');
|
|
}
|
|
|
|
async function verifyBoxUpdateInfo(versionsFile, updateInfo) {
|
|
assert.strictEqual(typeof versionsFile, 'string');
|
|
assert.strictEqual(typeof updateInfo, 'object');
|
|
|
|
const releases = safe.JSON.parse(safe.fs.readFileSync(versionsFile, 'utf8')) || {};
|
|
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;
|
|
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');
|
|
}
|
|
|
|
async function downloadAndVerifyBoxRelease(updateInfo) {
|
|
assert.strictEqual(typeof updateInfo, 'object');
|
|
|
|
const filenames = await fs.promises.readdir(os.tmpdir());
|
|
const oldArtifactNames = filenames.filter(f => f.startsWith('box-'));
|
|
for (const artifactName of oldArtifactNames) {
|
|
const fullPath = path.join(os.tmpdir(), artifactName);
|
|
debug(`downloadAndVerifyBoxRelease: removing old artifact ${fullPath}`);
|
|
await fs.promises.rm(fullPath, { recursive: true, force: true });
|
|
}
|
|
|
|
await downloadBoxUrl(updateInfo.boxVersionsUrl, `${paths.UPDATE_DIR}/versions.json`);
|
|
await downloadBoxUrl(updateInfo.boxVersionsSigUrl, `${paths.UPDATE_DIR}/versions.json.sig`);
|
|
await gpgVerifyBoxTarball(`${paths.UPDATE_DIR}/versions.json`, `${paths.UPDATE_DIR}/versions.json.sig`);
|
|
await verifyBoxUpdateInfo(`${paths.UPDATE_DIR}/versions.json`, updateInfo);
|
|
await downloadBoxUrl(updateInfo.sourceTarballUrl, `${paths.UPDATE_DIR}/box.tar.gz`);
|
|
await downloadBoxUrl(updateInfo.sourceTarballSigUrl, `${paths.UPDATE_DIR}/box.tar.gz.sig`);
|
|
await gpgVerifyBoxTarball(`${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));
|
|
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 extractBoxTarball(`${paths.UPDATE_DIR}/box.tar.gz`, newBoxSource);
|
|
|
|
return { file: newBoxSource };
|
|
}
|
|
|
|
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
|
|
const [error, diskUsage] = await safe(df.file('/'));
|
|
if (error) throw new BoxError(BoxError.FS_ERROR, error);
|
|
|
|
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)}`);
|
|
}
|
|
|
|
async function updateBox(boxUpdateInfo, options, progressCallback) {
|
|
assert(boxUpdateInfo && typeof boxUpdateInfo === 'object');
|
|
assert(options && typeof options === 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
progressCallback({ percent: 1, message: 'Checking disk space' });
|
|
|
|
await checkFreeDiskSpace(2*1024*1024*1024);
|
|
|
|
progressCallback({ percent: 5, message: 'Downloading and verifying release' });
|
|
|
|
const packageInfo = await downloadAndVerifyBoxRelease(boxUpdateInfo);
|
|
|
|
if (!options.skipBackup) {
|
|
progressCallback({ percent: 10, message: 'Backing up' });
|
|
|
|
const sites = await backupSites.listByContentForUpdates('box');
|
|
if (sites.length === 0) throw new BoxError(BoxError.BAD_STATE, 'no backup site for update');
|
|
|
|
for (const site of sites) {
|
|
await backuptask.fullBackup(site.id, { preserveSecs: 3*7*24*60*60 }, (progress) => progressCallback({ percent: 10+progress.percent*70/100, message: progress.message }));
|
|
}
|
|
|
|
await checkFreeDiskSpace(2*1024*1024*1024); // check again in case backup is in same disk
|
|
}
|
|
|
|
await locks.wait(locks.TYPE_BOX_UPDATE);
|
|
|
|
debug(`Updating box with ${boxUpdateInfo.sourceTarballUrl}`);
|
|
progressCallback({ percent: 70, message: 'Installing update...' });
|
|
const [error] = await safe(shell.sudo([ UPDATE_CMD, packageInfo.file, process.stdout.logFile ], {})); // run installer.sh from new box code as a separate service
|
|
if (error) await locks.release(locks.TYPE_BOX_UPDATE);
|
|
|
|
// Do not add any code here. The installer script will stop the box code any instant
|
|
}
|
|
|
|
async function checkBoxUpdateRequirements(boxUpdateInfo) {
|
|
assert.strictEqual(typeof boxUpdateInfo, 'object');
|
|
|
|
const result = await apps.list();
|
|
|
|
for (const app of result) {
|
|
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}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function getBoxUpdate() {
|
|
const updateInfo = safe.JSON.parse(safe.fs.readFileSync(paths.BOX_UPDATE_FILE, 'utf8'));
|
|
return updateInfo || null;
|
|
}
|
|
|
|
async function startBoxUpdateTask(options, auditSource) {
|
|
assert.strictEqual(typeof options, 'object');
|
|
assert.strictEqual(typeof auditSource, 'object');
|
|
|
|
const boxUpdateInfo = await getBoxUpdate();
|
|
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
|
|
|
|
await checkBoxUpdateRequirements(boxUpdateInfo);
|
|
|
|
const sites = await backupSites.listByContentForUpdates('box');
|
|
if (sites.length === 0) throw new BoxError(BoxError.BAD_STATE, 'No backup site for update');
|
|
|
|
const [error] = await safe(locks.acquire(locks.TYPE_BOX_UPDATE_TASK));
|
|
if (error) throw new BoxError(BoxError.BAD_STATE, `Another update task is in progress: ${error.message}`);
|
|
|
|
const memoryLimit = sites.reduce((acc, cur) => cur.limits?.memoryLimit ? Math.max(cur.limits.memoryLimit/1024/1024, acc) : acc, 400);
|
|
|
|
const taskId = await tasks.add(tasks.TASK_BOX_UPDATE, [ boxUpdateInfo, options ]);
|
|
await eventlog.add(eventlog.ACTION_UPDATE, auditSource, { taskId, boxUpdateInfo });
|
|
|
|
// background
|
|
tasks.startTask(taskId, { timeout: 20 * 60 * 60 * 1000 /* 20 hours */, nice: 15, memoryLimit })
|
|
.then(() => debug('startBoxUpdateTask: update task completed'))
|
|
.catch(async (error) => {
|
|
debug('Update failed with error. %o', error);
|
|
|
|
await locks.release(locks.TYPE_BOX_UPDATE_TASK);
|
|
await locks.releaseByTaskId(taskId);
|
|
|
|
const timedOut = error.code === tasks.ETIMEOUT;
|
|
await eventlog.add(eventlog.ACTION_UPDATE_FINISH, auditSource, { taskId, errorMessage: error.message, timedOut });
|
|
});
|
|
|
|
return taskId;
|
|
}
|
|
|
|
async function notifyBoxUpdate() {
|
|
const version = safe.fs.readFileSync(paths.VERSION_FILE, 'utf8');
|
|
if (version === constants.VERSION) return;
|
|
|
|
safe.fs.unlinkSync(paths.BOX_UPDATE_FILE);
|
|
|
|
if (!version) {
|
|
await eventlog.add(eventlog.ACTION_INSTALL_FINISH, AuditSource.CRON, { version: constants.VERSION });
|
|
} else {
|
|
debug(`notifyBoxUpdate: update finished from ${version} to ${constants.VERSION}`);
|
|
await eventlog.add(eventlog.ACTION_UPDATE_FINISH, AuditSource.CRON, { errorMessage: '', oldVersion: version || 'dev', newVersion: constants.VERSION });
|
|
await notifications.unpin(notifications.TYPE_BOX_UPDATE, { context: constants.VERSION });
|
|
const [error] = await safe(tasks.setCompletedByType(tasks.TASK_BOX_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');
|
|
}
|
|
|
|
async function autoUpdate(auditSource) {
|
|
assert.strictEqual(typeof auditSource, 'object');
|
|
|
|
const boxUpdateInfo = await getBoxUpdate();
|
|
// do box before app updates. for the off chance that the box logic fixes some app update logic issue
|
|
if (boxUpdateInfo && !boxUpdateInfo.unstable) {
|
|
debug('autoUpdate: starting box autoupdate to %j', boxUpdateInfo.version);
|
|
const [error] = await safe(startBoxUpdateTask({ skipBackup: false }, AuditSource.CRON));
|
|
if (!error) return; // do not start app updates when a box update got scheduled
|
|
debug(`autoUpdate: failed to start box autoupdate task: ${error.message}`);
|
|
// fall through to update apps if box update never started (failed ubuntu or avx check)
|
|
}
|
|
|
|
const result = await apps.list();
|
|
for (const app of result) {
|
|
if (!app.updateInfo) continue;
|
|
if (!app.updateInfo.isAutoUpdatable) {
|
|
debug(`autoUpdate: ${app.fqdn} requires manual update. skipping`);
|
|
continue;
|
|
}
|
|
|
|
const sites = await backupSites.listByContentForUpdates(app.id);
|
|
if (sites.length === 0) {
|
|
debug(`autoUpdate: ${app.fqdn} has no backup site for updates. skipping`);
|
|
continue;
|
|
}
|
|
|
|
const data = {
|
|
manifest: app.updateInfo.manifest,
|
|
force: false
|
|
};
|
|
|
|
debug(`autoUpdate: ${app.fqdn} will be automatically updated`);
|
|
const [updateError] = await safe(apps.updateApp(app, data, auditSource));
|
|
if (updateError) debug(`autoUpdate: error autoupdating ${app.fqdn}: ${updateError.message}`);
|
|
}
|
|
}
|
|
|
|
async function checkAppUpdate(app, options) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof options, 'object');
|
|
|
|
if (app.appStoreId === '') return null; // appStoreId can be '' for dev apps
|
|
|
|
const updateInfo = await appstore.getAppUpdate(app, options);
|
|
await apps.update(app.id, { updateInfo });
|
|
return updateInfo;
|
|
}
|
|
|
|
async function checkBoxUpdate(options) {
|
|
assert.strictEqual(typeof options, 'object');
|
|
|
|
debug('checkBoxUpdate: checking for updates');
|
|
|
|
const updateInfo = await appstore.getBoxUpdate(options);
|
|
if (updateInfo) {
|
|
safe.fs.writeFileSync(paths.BOX_UPDATE_FILE, JSON.stringify(updateInfo, null, 4));
|
|
} else {
|
|
safe.fs.unlinkSync(paths.BOX_UPDATE_FILE);
|
|
}
|
|
}
|
|
|
|
async function raiseNotifications() {
|
|
const pattern = await getAutoupdatePattern();
|
|
|
|
const boxUpdate = await getBoxUpdate();
|
|
if (pattern === constants.CRON_PATTERN_NEVER && boxUpdate) {
|
|
const changelog = boxUpdate.changelog.map((m) => `* ${m}\n`).join('');
|
|
const message = `Changelog:\n${changelog}\n\nGo to the Settings view to update.\n\n`;
|
|
|
|
await notifications.pin(notifications.TYPE_BOX_UPDATE, `Cloudron v${boxUpdate.version} is available`, message, { context: boxUpdate.version });
|
|
}
|
|
|
|
const result = await apps.list();
|
|
for (const app of result) {
|
|
// currently, we do not raise notifications when auto-update is disabled. separate notifications appears spammy when having many apps
|
|
// in the future, we can maybe aggregate?
|
|
if (app.updateInfo && !app.updateInfo.isAutoUpdatable) {
|
|
debug(`autoUpdate: ${app.fqdn} cannot be autoupdated. skipping`);
|
|
await notifications.pin(notifications.TYPE_MANUAL_APP_UPDATE_NEEDED, `${app.manifest.title} at ${app.fqdn} requires manual update to version ${app.updateInfo.manifest.version}`,
|
|
`Changelog:\n${app.updateInfo.manifest.changelog}\n`, { context: app.id });
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function checkForUpdates(options) {
|
|
assert.strictEqual(typeof options, 'object');
|
|
|
|
const [boxError] = await safe(checkBoxUpdate(options));
|
|
if (boxError) debug('checkForUpdates: error checking for box updates: %o', boxError);
|
|
|
|
// check app updates
|
|
const result = await apps.list();
|
|
for (const app of result) {
|
|
await safe(checkAppUpdate(app, options), { debug });
|
|
}
|
|
|
|
// raise notifications here because the updatechecker runs regardless of auto-updater cron job
|
|
await raiseNotifications();
|
|
}
|