Files
cloudron-box/src/updater.js
Girish Ramakrishnan 4ed6fbbd74 eslint: add no-shadow
2026-02-18 08:18:37 +01:00

384 lines
17 KiB
JavaScript

import apps from './apps.js';
import appstore from './appstore.js';
import assert from 'node:assert';
import AuditSource from './auditsource.js';
import BoxError from './boxerror.js';
import backupSites from './backupsites.js';
import backuptask from './backuptask.js';
import community from './community.js';
import constants from './constants.js';
import cron from './cron.js';
import { CronTime } from 'cron';
import crypto from 'node:crypto';
import debugModule from 'debug';
import df from './df.js';
import eventlog from './eventlog.js';
import fs from 'node:fs';
import locks from './locks.js';
import notifications from './notifications.js';
import os from 'node:os';
import path from 'node:path';
import paths from './paths.js';
import promiseRetry from './promise-retry.js';
import safe from 'safetydance';
import semver from 'semver';
import settings from './settings.js';
import shellModule from './shell.js';
import tasks from './tasks.js';
const debug = debugModule('box:updater');
const shell = shellModule('updater');
const RELEASES_PUBLIC_KEY = path.join(import.meta.dirname, 'releases.gpg');
const UPDATE_CMD = path.join(import.meta.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], { encoding: 'utf8' }));
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], { encoding: 'utf8' }));
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 is enabled to store automatic-update backups');
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 (!options.skipBackup && sites.length === 0) throw new BoxError(BoxError.BAD_STATE, 'No backup site is enabled to store automatic-update backups. Enable this in a backup site to continue.');
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, 800);
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 (updateError) => {
debug('Update failed with error. %o', updateError);
await locks.release(locks.TYPE_BOX_UPDATE_TASK);
await locks.releaseByTaskId(taskId);
const timedOut = updateError.code === tasks.ETIMEOUT;
await eventlog.add(eventlog.ACTION_UPDATE_FINISH, auditSource, { taskId, errorMessage: updateError.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}`);
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
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 });
}
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) {
const updateInfo = await appstore.getAppUpdate(app, options);
await apps.update(app.id, { updateInfo });
return updateInfo;
} else if (app.versionsUrl) {
const updateInfo = await community.getAppUpdate(app, options);
await apps.update(app.id, { updateInfo });
return updateInfo;
} else {
return null;
}
}
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();
}
export default {
setAutoupdatePattern,
getAutoupdatePattern,
startBoxUpdateTask,
updateBox,
autoUpdate,
notifyBoxUpdate,
checkForUpdates,
checkAppUpdate,
checkBoxUpdate,
getBoxUpdate,
};