diff --git a/src/apps.js b/src/apps.js index f7dfceeb6..30e61c36b 100644 --- a/src/apps.js +++ b/src/apps.js @@ -180,7 +180,7 @@ const appTaskManager = require('./apptaskmanager.js'), tasks = require('./tasks.js'), tgz = require('./backupformat/tgz.js'), TransformStream = require('stream').Transform, - updateChecker = require('./updatechecker.js'), + updater = require('./updater.js'), users = require('./users.js'), util = require('util'), uuid = require('uuid'), @@ -759,7 +759,7 @@ function postProcess(result) { } // note: this value cannot be cached as it depends on enableAutomaticUpdate and runState -function canAutoupdateApp(app, updateInfo) { +function canAutoupdateAppSync(app, updateInfo) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof updateInfo, 'object'); @@ -801,9 +801,9 @@ function attachProperties(app, domainObjectMap) { app.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); }); app.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); }); - const updateInfo = updateChecker.getAppUpdateInfoSync(app.id); + const updateInfo = updater.getAppUpdateInfoSync(app.id); if (updateInfo) { - const { code, reason } = canAutoupdateApp(app, updateInfo); // isAutoUpdatable is not cached since it depends on enableAutomaticUpdate and runState + const { code, reason } = canAutoupdateAppSync(app, updateInfo); // isAutoUpdatable is not cached since it depends on enableAutomaticUpdate and runState updateInfo.isAutoUpdatable = code; updateInfo.manualUpdateReason = reason; } diff --git a/src/cron.js b/src/cron.js index 2c44ba527..fd47cfe80 100644 --- a/src/cron.js +++ b/src/cron.js @@ -42,8 +42,7 @@ const appHealthMonitor = require('./apphealthmonitor.js'), safe = require('safetydance'), scheduler = require('./scheduler.js'), system = require('./system.js'), - updater = require('./updater.js'), - updateChecker = require('./updatechecker.js'); + updater = require('./updater.js'); const gJobs = { autoUpdater: null, @@ -130,7 +129,7 @@ async function startJobs() { // this is run separately from the update itself so that the user can disable automatic updates but can still get a notification gJobs.updateCheckerJob = CronJob.from({ cronTime: `00 ${minute} 1,5,9,13,17,21,23 * * *`, - onTick: async () => await safe(updateChecker.checkForUpdates({ stableOnly: true }), { debug }), + onTick: async () => await safe(updater.checkForUpdates({ stableOnly: true }), { debug }), start: true }); diff --git a/src/routes/apps.js b/src/routes/apps.js index b6ac828e0..cadbb6c69 100644 --- a/src/routes/apps.js +++ b/src/routes/apps.js @@ -81,7 +81,7 @@ const apps = require('../apps.js'), HttpSuccess = require('connect-lastmile').HttpSuccess, metrics = require('../metrics.js'), safe = require('safetydance'), - updateChecker = require('../updatechecker.js'), + updater = require('../updater.js'), users = require('../users.js'), WebSocket = require('ws'); @@ -1049,8 +1049,9 @@ async function checkForUpdates(req, res, next) { // it can take a while sometimes to get all the app updates one by one req.clearTimeout(); - await updateChecker.checkForUpdates({ stableOnly: false }); // appId argument is ignored for the moment - next(new HttpSuccess(200, { update: updateChecker.getUpdateInfo() })); + const [error, result] = await safe(updater.checkForUpdates({ stableOnly: false })); // appId argument is ignored for the moment + if (error) return next(BoxError.toHttpError(error)); + next(new HttpSuccess(200, { update: result[req.resource.app.id] })); } async function getTask(req, res, next) { diff --git a/src/routes/updater.js b/src/routes/updater.js index 61692f803..b6c0777e1 100644 --- a/src/routes/updater.js +++ b/src/routes/updater.js @@ -15,8 +15,7 @@ const assert = require('assert'), HttpError = require('connect-lastmile').HttpError, HttpSuccess = require('connect-lastmile').HttpSuccess, safe = require('safetydance'), - updater = require('../updater.js'), - updateChecker = require('../updatechecker.js'); + updater = require('../updater.js'); async function getAutoupdatePattern(req, res, next) { const [error, pattern] = await safe(updater.getAutoupdatePattern()); @@ -49,13 +48,15 @@ async function update(req, res, next) { } function getUpdateInfo(req, res, next) { - next(new HttpSuccess(200, { update: updateChecker.getUpdateInfo() })); + next(new HttpSuccess(200, { update: updater.getUpdateInfoSync() })); } async function checkForUpdates(req, res, next) { // it can take a while sometimes to get all the app updates one by one req.clearTimeout(); - await updateChecker.checkForUpdates({ stableOnly: false }); - next(new HttpSuccess(200, { update: updateChecker.getUpdateInfo() })); + const [error, result ] = await safe(updater.checkForUpdates({ stableOnly: false })); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(200, { update: result })); } diff --git a/src/test/updatechecker-test.js b/src/test/updatechecker-test.js deleted file mode 100644 index cc86d9272..000000000 --- a/src/test/updatechecker-test.js +++ /dev/null @@ -1,130 +0,0 @@ -/* global it:false */ -/* global describe:false */ -/* global before:false */ -/* global after:false */ - -'use strict'; - -const common = require('./common.js'), - constants = require('../constants.js'), - expect = require('expect.js'), - nock = require('nock'), - paths = require('../paths.js'), - safe = require('safetydance'), - semver = require('semver'), - updatechecker = require('../updatechecker.js'), - updater = require('../updater.js'); - -const UPDATE_VERSION = semver.inc(constants.VERSION, 'major'); - -describe('updatechecker', function () { - const { setup, cleanup, app, appstoreToken, mockApiServerOrigin } = common; - - before(setup); - before(() => { if (!nock.isActive()) nock.activate(); }); - after(cleanup); - - describe('box', function () { - before(async function () { - safe.fs.unlinkSync(paths.UPDATE_CHECKER_FILE); - - await updater.setAutoupdatePattern(constants.AUTOUPDATE_PATTERN_NEVER); - }); - - it('no updates', async function () { - nock.cleanAll(); - - const scope = nock(mockApiServerOrigin) - .get('/api/v1/boxupdate') - .query({ boxVersion: constants.VERSION, accessToken: appstoreToken, stableOnly: false }) - .reply(204, { } ); - - await updatechecker.checkForUpdates({ stableOnly: false }); - expect(updatechecker.getUpdateInfo().box).to.not.be.ok(); - expect(scope.isDone()).to.be.ok(); - }); - - it('new version', async function () { - nock.cleanAll(); - - const scope = nock(mockApiServerOrigin) - .get('/api/v1/boxupdate') - .query({ boxVersion: constants.VERSION, accessToken: appstoreToken, stableOnly: false }) - .reply(200, { version: UPDATE_VERSION, changelog: [''], sourceTarballUrl: 'box.tar.gz', sourceTarballSigUrl: 'box.tar.gz.sig', boxVersionsUrl: 'box.versions', boxVersionsSigUrl: 'box.versions.sig', unstable: false } ); - - await updatechecker.checkForUpdates({ stableOnly: false }); - expect(updatechecker.getUpdateInfo().box).to.be.ok(); - expect(updatechecker.getUpdateInfo().box.version).to.be(UPDATE_VERSION); - expect(updatechecker.getUpdateInfo().box.sourceTarballUrl).to.be('box.tar.gz'); - expect(scope.isDone()).to.be.ok(); - }); - - it('bad response offers whatever was last valid', async function () { - nock.cleanAll(); - - const scope = nock(mockApiServerOrigin) - .get('/api/v1/boxupdate') - .query({ boxVersion: constants.VERSION, accessToken: appstoreToken, stableOnly: false }) - .reply(404, { version: '2.0.0-pre.0', changelog: [''], sourceTarballUrl: 'box-pre.tar.gz' } ); - - await updatechecker.checkForUpdates({ stableOnly: false }); - expect(updatechecker.getUpdateInfo().box.version).to.be(UPDATE_VERSION); - expect(updatechecker.getUpdateInfo().box.sourceTarballUrl).to.be('box.tar.gz'); - expect(scope.isDone()).to.be.ok(); - }); - }); - - describe('app', function () { - before(async function () { - safe.fs.unlinkSync(paths.UPDATE_CHECKER_FILE); - - await updater.setAutoupdatePattern(constants.AUTOUPDATE_PATTERN_NEVER); - }); - - it('no updates', async function () { - nock.cleanAll(); - - const scope = nock(mockApiServerOrigin) - .get('/api/v1/appupdate') - .query({ boxVersion: constants.VERSION, accessToken: appstoreToken, appId: app.appStoreId, appVersion: app.manifest.version, stableOnly: false }) - .reply(204, { } ); - - await updatechecker._checkAppUpdates({ stableOnly: false }); - expect(updatechecker.getUpdateInfo()).to.eql({}); - expect(scope.isDone()).to.be.ok(); - }); - - it('bad response', async function () { - nock.cleanAll(); - - const scope = nock(mockApiServerOrigin) - .get('/api/v1/appupdate') - .query({ boxVersion: constants.VERSION, accessToken: appstoreToken, appId: app.appStoreId, appVersion: app.manifest.version, stableOnly: false }) - .reply(500, { update: { manifest: { version: '1.0.0', changelog: '* some changes' } } } ); - - await updatechecker._checkAppUpdates({ stableOnly: false }); - expect(updatechecker.getUpdateInfo()).to.eql({}); - expect(scope.isDone()).to.be.ok(); - }); - - it('offers new version', async function () { - nock.cleanAll(); - - const scope = nock(mockApiServerOrigin) - .get('/api/v1/appupdate') - .query({ boxVersion: constants.VERSION, accessToken: appstoreToken, appId: app.appStoreId, appVersion: app.manifest.version, stableOnly: false }) - .reply(200, { manifest: { version: '2.0.0', changelog: '* some changes' } } ); - - await updatechecker._checkAppUpdates({ stableOnly: false }); - expect(updatechecker.getUpdateInfo()).to.eql({ 'appid': { manifest: { version: '2.0.0', changelog: '* some changes' }, unstable: false } }); - expect(scope.isDone()).to.be.ok(); - }); - - it('does not offer old version', async function () { - nock.cleanAll(); - - await updatechecker._checkAppUpdates({ stableOnly: false }); - expect(updatechecker.getUpdateInfo()).to.eql({ }); - }); - }); -}); diff --git a/src/test/updater-test.js b/src/test/updater-test.js index e299447a5..22382df26 100644 --- a/src/test/updater-test.js +++ b/src/test/updater-test.js @@ -7,27 +7,145 @@ const BoxError = require('../boxerror.js'), common = require('./common.js'), + constants = require('../constants.js'), expect = require('expect.js'), + nock = require('nock'), + paths = require('../paths.js'), safe = require('safetydance'), + semver = require('semver'), updater = require('../updater.js'); +const UPDATE_VERSION = semver.inc(constants.VERSION, 'major'); + describe('updater', function () { - const { setup, cleanup } = common; + const { setup, cleanup, app, appstoreToken, mockApiServerOrigin } = common; - before(setup); - after(cleanup); + describe('settings', function () { + before(setup); + after(cleanup); - it('can get default autoupdate_pattern', async function () { - const pattern = await updater.getAutoupdatePattern(); - expect(pattern).to.be('00 00 1,3,5,23 * * *'); + it('can get default autoupdate_pattern', async function () { + const pattern = await updater.getAutoupdatePattern(); + expect(pattern).to.be('00 00 1,3,5,23 * * *'); + }); + + it('cannot set invalid autoupdate_pattern', async function () { + const [error] = await safe(updater.setAutoupdatePattern('02 * 1 *')); + expect(error.reason).to.be(BoxError.BAD_FIELD); + }); + + it('can set default autoupdate_pattern', async function () { + await updater.setAutoupdatePattern('02 * 1-5 * * *'); + }); }); - it('cannot set invalid autoupdate_pattern', async function () { - const [error] = await safe(updater.setAutoupdatePattern('02 * 1 *')); - expect(error.reason).to.be(BoxError.BAD_FIELD); - }); + describe('checker', function () { + before(setup); + before(() => { if (!nock.isActive()) nock.activate(); }); + after(cleanup); - it('can set default autoupdate_pattern', async function () { - await updater.setAutoupdatePattern('02 * 1-5 * * *'); + describe('box updates', function () { + before(async function () { + safe.fs.unlinkSync(paths.UPDATE_CHECKER_FILE); + + await updater.setAutoupdatePattern(constants.AUTOUPDATE_PATTERN_NEVER); + }); + + it('no updates', async function () { + nock.cleanAll(); + + const scope = nock(mockApiServerOrigin) + .get('/api/v1/boxupdate') + .query({ boxVersion: constants.VERSION, accessToken: appstoreToken, stableOnly: false }) + .reply(204, { } ); + + await updater.checkForUpdates({ stableOnly: false }); + expect(updater.getUpdateInfoSync().box).to.not.be.ok(); + expect(scope.isDone()).to.be.ok(); + }); + + it('new version', async function () { + nock.cleanAll(); + + const scope = nock(mockApiServerOrigin) + .get('/api/v1/boxupdate') + .query({ boxVersion: constants.VERSION, accessToken: appstoreToken, stableOnly: false }) + .reply(200, { version: UPDATE_VERSION, changelog: [''], sourceTarballUrl: 'box.tar.gz', sourceTarballSigUrl: 'box.tar.gz.sig', boxVersionsUrl: 'box.versions', boxVersionsSigUrl: 'box.versions.sig', unstable: false } ); + + await updater.checkForUpdates({ stableOnly: false }); + expect(updater.getUpdateInfoSync().box).to.be.ok(); + expect(updater.getUpdateInfoSync().box.version).to.be(UPDATE_VERSION); + expect(updater.getUpdateInfoSync().box.sourceTarballUrl).to.be('box.tar.gz'); + expect(scope.isDone()).to.be.ok(); + }); + + it('bad response offers whatever was last valid', async function () { + nock.cleanAll(); + + const scope = nock(mockApiServerOrigin) + .get('/api/v1/boxupdate') + .query({ boxVersion: constants.VERSION, accessToken: appstoreToken, stableOnly: false }) + .reply(404, { version: '2.0.0-pre.0', changelog: [''], sourceTarballUrl: 'box-pre.tar.gz' } ); + + await updater.checkForUpdates({ stableOnly: false }); + expect(updater.getUpdateInfoSync().box.version).to.be(UPDATE_VERSION); + expect(updater.getUpdateInfoSync().box.sourceTarballUrl).to.be('box.tar.gz'); + expect(scope.isDone()).to.be.ok(); + }); + }); + + describe('app updates', function () { + before(async function () { + safe.fs.unlinkSync(paths.UPDATE_CHECKER_FILE); + + await updater.setAutoupdatePattern(constants.AUTOUPDATE_PATTERN_NEVER); + }); + + it('no updates', async function () { + nock.cleanAll(); + + const scope = nock(mockApiServerOrigin) + .get('/api/v1/appupdate') + .query({ boxVersion: constants.VERSION, accessToken: appstoreToken, appId: app.appStoreId, appVersion: app.manifest.version, stableOnly: false }) + .reply(204, { } ); + + await updater._checkAppUpdates({ stableOnly: false }); + expect(updater.getUpdateInfoSync()).to.eql({}); + expect(scope.isDone()).to.be.ok(); + }); + + it('bad response', async function () { + nock.cleanAll(); + + const scope = nock(mockApiServerOrigin) + .get('/api/v1/appupdate') + .query({ boxVersion: constants.VERSION, accessToken: appstoreToken, appId: app.appStoreId, appVersion: app.manifest.version, stableOnly: false }) + .reply(500, { update: { manifest: { version: '1.0.0', changelog: '* some changes' } } } ); + + await updater._checkAppUpdates({ stableOnly: false }); + expect(updater.getUpdateInfoSync()).to.eql({}); + expect(scope.isDone()).to.be.ok(); + }); + + it('offers new version', async function () { + nock.cleanAll(); + + const scope = nock(mockApiServerOrigin) + .get('/api/v1/appupdate') + .query({ boxVersion: constants.VERSION, accessToken: appstoreToken, appId: app.appStoreId, appVersion: app.manifest.version, stableOnly: false }) + .reply(200, { manifest: { version: '2.0.0', changelog: '* some changes' } } ); + + await updater._checkAppUpdates({ stableOnly: false }); + expect(updater.getUpdateInfoSync()).to.eql({ 'appid': { manifest: { version: '2.0.0', changelog: '* some changes' }, unstable: false } }); + expect(scope.isDone()).to.be.ok(); + }); + + it('does not offer old version', async function () { + nock.cleanAll(); + + await updater._checkAppUpdates({ stableOnly: false }); + expect(updater.getUpdateInfoSync()).to.eql({ }); + }); + }); }); }); diff --git a/src/updatechecker.js b/src/updatechecker.js deleted file mode 100644 index cc8fa06c3..000000000 --- a/src/updatechecker.js +++ /dev/null @@ -1,130 +0,0 @@ -'use strict'; - -exports = module.exports = { - checkForUpdates, - getAppUpdateInfoSync, - - getUpdateInfo, - - _checkAppUpdates: checkAppUpdates -}; - -const apps = require('./apps.js'), - appstore = require('./appstore.js'), - assert = require('assert'), - constants = require('./constants.js'), - debug = require('debug')('box:updatechecker'), - notifications = require('./notifications.js'), - paths = require('./paths.js'), - safe = require('safetydance'), - updater = require('./updater.js'); - -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 getUpdateInfo() { - 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 getUpdateInfo()[appId] || null; -} - -async function checkAppUpdates(options) { - assert.strictEqual(typeof options, 'object'); - - debug('checkAppUpdates: checking for updates'); - - const state = getUpdateInfo(); - 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 = getUpdateInfo(); - - 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 = getUpdateInfo(); - - const pattern = await updater.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(); -} diff --git a/src/updater.js b/src/updater.js index 8e008740a..4505a47e8 100644 --- a/src/updater.js +++ b/src/updater.js @@ -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(); +}