merge updatechecker into updater

This commit is contained in:
Girish Ramakrishnan
2025-06-26 13:41:09 +02:00
parent a085e9ed54
commit abd640d36b
8 changed files with 292 additions and 314 deletions
+146 -27
View File
@@ -9,10 +9,18 @@ exports = module.exports = {
autoUpdate,
notifyBoxUpdate
notifyBoxUpdate,
checkForUpdates,
getAppUpdateInfoSync,
getUpdateInfoSync,
_checkAppUpdates: checkAppUpdates
};
const apps = require('./apps.js'),
appstore = require('./appstore.js'),
assert = require('assert'),
AuditSource = require('./auditsource.js'),
BoxError = require('./boxerror.js'),
@@ -37,7 +45,6 @@ const apps = require('./apps.js'),
settings = require('./settings.js'),
shell = require('./shell.js')('updater'),
tasks = require('./tasks.js'),
updateChecker = require('./updatechecker.js'),
_ = require('./underscore.js');
const RELEASES_PUBLIC_KEY = path.join(__dirname, 'releases.gpg');
@@ -60,7 +67,7 @@ async function getAutoupdatePattern() {
return pattern || cron.DEFAULT_AUTOUPDATE_PATTERN;
}
async function downloadUrl(url, file) {
async function downloadBoxUrl(url, file) {
assert.strictEqual(typeof file, 'string');
// do not assert since it comes from the appstore
@@ -69,44 +76,44 @@ async function downloadUrl(url, file) {
safe.fs.unlinkSync(file);
await promiseRetry({ times: 10, interval: 5000, debug }, async function () {
debug(`downloadUrl: downloading ${url} to ${file}`);
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('downloadUrl: done');
debug('downloadBoxUrl: done');
});
}
async function gpgVerify(file, sig) {
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(`gpgVerify: command failed. error: ${error}\n stdout: ${error.stdout}\n stderr: ${error.stderr}`);
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(`gpgVerify: verification of ${sig} failed: ${stdout}\n`);
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 extractTarball(tarball, dir) {
async function extractBoxTarball(tarball, dir) {
assert.strictEqual(typeof tarball, 'string');
assert.strictEqual(typeof dir, 'string');
debug(`extractTarball: extracting ${tarball} to ${dir}`);
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('extractTarball: extracted');
debug('extractBoxTarball: extracted');
}
async function verifyUpdateInfo(versionsFile, updateInfo) {
async function verifyBoxUpdateInfo(versionsFile, updateInfo) {
assert.strictEqual(typeof versionsFile, 'string');
assert.strictEqual(typeof updateInfo, 'object');
@@ -118,29 +125,29 @@ async function verifyUpdateInfo(versionsFile, updateInfo) {
if (releases[nextVersion].sourceTarballUrl !== updateInfo.sourceTarballUrl) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Version info mismatch');
}
async function downloadAndVerifyRelease(updateInfo) {
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(`downloadAndVerifyRelease: removing old artifact ${fullPath}`);
debug(`downloadAndVerifyBoxRelease: removing old artifact ${fullPath}`);
await fs.promises.rm(fullPath, { recursive: true, force: true });
}
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`);
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 extractTarball(`${paths.UPDATE_DIR}/box.tar.gz`, newBoxSource);
await extractBoxTarball(`${paths.UPDATE_DIR}/box.tar.gz`, newBoxSource);
return { file: newBoxSource };
}
@@ -166,7 +173,7 @@ async function updateBox(boxUpdateInfo, options, progressCallback) {
progressCallback({ percent: 5, message: 'Downloading and verifying release' });
const packageInfo = await downloadAndVerifyRelease(boxUpdateInfo);
const packageInfo = await downloadAndVerifyBoxRelease(boxUpdateInfo);
if (!options.skipBackup) {
progressCallback({ percent: 10, message: 'Backing up' });
@@ -186,7 +193,7 @@ async function updateBox(boxUpdateInfo, options, progressCallback) {
// Do not add any code here. The installer script will stop the box code any instant
}
async function checkUpdateRequirements(boxUpdateInfo) {
async function checkBoxUpdateRequirements(boxUpdateInfo) {
assert.strictEqual(typeof boxUpdateInfo, 'object');
const result = await apps.list();
@@ -203,12 +210,12 @@ async function startBoxUpdateTask(options, auditSource) {
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof auditSource, 'object');
const boxUpdateInfo = updateChecker.getUpdateInfo().box;
const boxUpdateInfo = getUpdateInfoSync().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
await checkUpdateRequirements(boxUpdateInfo);
await checkBoxUpdateRequirements(boxUpdateInfo);
const [error] = await safe(locks.acquire(locks.TYPE_UPDATE_TASK));
if (error) throw new BoxError(BoxError.BAD_STATE, `Another update task is in progress: ${error.message}`);
@@ -254,7 +261,7 @@ async function notifyBoxUpdate() {
async function autoUpdate(auditSource) {
assert.strictEqual(typeof auditSource, 'object');
const updateInfo = updateChecker.getUpdateInfo();
const updateInfo = getUpdateInfoSync();
debug('autoUpdate: available updates: %j', Object.keys(updateInfo));
// do box before app updates. for the off chance that the box logic fixes some app update logic issue
@@ -291,3 +298,115 @@ async function autoUpdate(auditSource) {
if (updateError) debug(`autoUpdate: error autoupdating ${app.fqdn}: ${updateError.message}`);
}
}
function setUpdateInfo(state) {
// appid -> update info { creationDate, manifest }
// box -> { version, changelog, upgrade, sourceTarballUrl }
state.version = 2;
if (!safe.fs.writeFileSync(paths.UPDATE_CHECKER_FILE, JSON.stringify(state, null, 4), 'utf8')) debug(`setUpdateInfo: Error writing to update checker file: ${safe.error.message}`);
}
function getUpdateInfoSync() {
const state = safe.JSON.parse(safe.fs.readFileSync(paths.UPDATE_CHECKER_FILE, 'utf8'));
if (!state || state.version !== 2) return {};
delete state.version;
return state;
}
function getAppUpdateInfoSync(appId) {
assert.strictEqual(typeof appId, 'string');
return getUpdateInfoSync()[appId] || null;
}
async function checkAppUpdates(options) {
assert.strictEqual(typeof options, 'object');
debug('checkAppUpdates: checking for updates');
const state = getUpdateInfoSync();
const newState = { }; // create new state so that old app ids are removed
const result = await apps.list();
for (const app of result) {
if (app.appStoreId === '') continue; // appStoreId can be '' for dev apps
const [error, updateInfo] = await safe(appstore.getAppUpdate(app, options));
if (error) {
debug('checkAppUpdates: Error getting app update info for %s', app.id, error);
continue; // continue to next
}
if (!updateInfo) continue; // skip if no next version is found
newState[app.id] = updateInfo;
}
if ('box' in state) newState.box = state.box; // preserve the latest box state information
setUpdateInfo(newState);
}
async function checkBoxUpdates(options) {
assert.strictEqual(typeof options, 'object');
debug('checkBoxUpdates: checking for updates');
const updateInfo = await appstore.getBoxUpdate(options);
const state = getUpdateInfoSync();
if (!updateInfo) { // no update
if ('box' in state) {
delete state.box;
setUpdateInfo(state);
}
debug('checkBoxUpdates: no updates');
return;
}
debug(`checkBoxUpdates: ${updateInfo.version} is available`);
state.box = updateInfo;
setUpdateInfo(state);
}
async function raiseNotifications() {
const state = getUpdateInfoSync();
const pattern = await getAutoupdatePattern();
if (pattern === constants.AUTOUPDATE_PATTERN_NEVER && state.box) {
const updateInfo = state.box;
const changelog = updateInfo.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${updateInfo.version} is available`, message, { context: updateInfo.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(checkBoxUpdates(options));
if (boxError) debug('checkForUpdates: error checking for box updates: %o', boxError);
const [appError] = await safe(checkAppUpdates(options));
if (appError) debug('checkForUpdates: error checking for app updates: %o', appError);
// raise notifications here because the updatechecker runs regardless of auto-updater cron job
await raiseNotifications();
return getUpdateInfoSync();
}