diff --git a/src/apps.js b/src/apps.js index 20870ef34..f7dfceeb6 100644 --- a/src/apps.js +++ b/src/apps.js @@ -79,9 +79,6 @@ exports = module.exports = { checkManifest, - canAutoupdateApp, - autoupdateApps, - restoreApps, configureApps, schedulePendingTasks, @@ -761,6 +758,38 @@ function postProcess(result) { delete result.devicesJson; } +// note: this value cannot be cached as it depends on enableAutomaticUpdate and runState +function canAutoupdateApp(app, updateInfo) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof updateInfo, 'object'); + + const manifest = updateInfo.manifest; + + if (!app.enableAutomaticUpdate) return { code: false, reason: 'Automatic updates for the app is disabled' }; + + // for invalid subscriptions the appstore does not return a dockerImage + if (!manifest.dockerImage) return { code: false, reason: 'Invalid or Expired subscription '}; + + if (updateInfo.unstable) return { code: false, reason: 'Update is marked as unstable' }; // only manual update allowed for unstable updates + + if ((semver.major(app.manifest.version) !== 0) && (semver.major(app.manifest.version) !== semver.major(manifest.version))) { + return { code: false, reason: 'Major package version requires review of breaking changes' }; // major changes are blocking + } + + if (app.runState === exports.RSTATE_STOPPED) return { code: false, reason: 'Stopped apps cannot run migration scripts' }; + + const newTcpPorts = manifest.tcpPorts || {}; + const newUdpPorts = manifest.udpPorts || {}; + const portBindings = app.portBindings; // this is never null + + for (const portName in portBindings) { + if (!(portName in newTcpPorts) && !(portName in newUdpPorts)) return { code: false, reason: `${portName} port was in use but new update removes it` }; + } + + // it's fine if one or more (unused) port keys got removed + return { code: true, reason: '' }; +} + // attaches computed properties function attachProperties(app, domainObjectMap) { assert.strictEqual(typeof app, 'object'); @@ -771,7 +800,14 @@ function attachProperties(app, domainObjectMap) { app.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); }); 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); }); - app.updateInfo = updateChecker.getAppUpdateInfoSync(app.id); + + const updateInfo = updateChecker.getAppUpdateInfoSync(app.id); + if (updateInfo) { + const { code, reason } = canAutoupdateApp(app, updateInfo); // isAutoUpdatable is not cached since it depends on enableAutomaticUpdate and runState + updateInfo.isAutoUpdatable = code; + updateInfo.manualUpdateReason = reason; + } + app.updateInfo = updateInfo; } function isAdmin(user) { @@ -2728,64 +2764,6 @@ async function getExec(app, execId) { return await docker.getExec(execId); } -function canAutoupdateApp(app, updateInfo) { - assert.strictEqual(typeof app, 'object'); - assert.strictEqual(typeof updateInfo, 'object'); - - const manifest = updateInfo.manifest; - - if (!app.enableAutomaticUpdate) return false; - - // for invalid subscriptions the appstore does not return a dockerImage - if (!manifest.dockerImage) return false; - - if (updateInfo.unstable) return false; // only manual update allowed for unstable updates - - if ((semver.major(app.manifest.version) !== 0) && (semver.major(app.manifest.version) !== semver.major(manifest.version))) return false; // major changes are blocking - - if (app.runState === exports.RSTATE_STOPPED) return false; // stopped apps won't run migration scripts and shouldn't be updated - - const newTcpPorts = manifest.tcpPorts || { }; - const newUdpPorts = manifest.udpPorts || { }; - const portBindings = app.portBindings; // this is never null - - for (const portName in portBindings) { - if (!(portName in newTcpPorts) && !(portName in newUdpPorts)) return false; // portName was in use but new update removes it - } - - // it's fine if one or more (unused) keys got removed - return true; -} - -async function autoupdateApps(updateInfo, auditSource) { // updateInfo is { appId -> { manifest } } - assert.strictEqual(typeof updateInfo, 'object'); - assert.strictEqual(typeof auditSource, 'object'); - - for (const appId of Object.keys(updateInfo)) { - const [getError, app] = await safe(get(appId)); - if (getError) { - debug(`Cannot autoupdate app ${appId}: ${getError.message}`); - continue; - } - - if (!canAutoupdateApp(app, updateInfo[appId])) { - debug(`app ${app.fqdn} requires manual update`); - await notifications.pin(notifications.TYPE_MANUAL_APP_UPDATE_NEEDED, `${app.manifest.title} at ${app.fqdn} requires manual update to version ${updateInfo[appId].manifest.version}`, - `Changelog:\n${updateInfo[appId].manifest.changelog}\n`, { context: app.id }); - continue; - } - - const data = { - manifest: updateInfo[appId].manifest, - force: false - }; - - debug(`app ${app.fqdn} will be automatically updated`); - const [updateError] = await safe(updateApp(app, data, auditSource)); - if (updateError) debug(`Error autoupdating ${appId}. ${updateError.message}`); - } -} - async function backup(app, auditSource) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof auditSource, 'object'); diff --git a/src/updater.js b/src/updater.js index 3dfaf7ef0..2310bccef 100644 --- a/src/updater.js +++ b/src/updater.js @@ -255,6 +255,8 @@ async function autoUpdate(auditSource) { assert.strictEqual(typeof auditSource, 'object'); const updateInfo = updateChecker.getUpdateInfo(); + 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 if (updateInfo.box && !updateInfo.box.unstable) { debug('autoUpdate: starting box autoupdate to %j', updateInfo.box.version); @@ -264,12 +266,28 @@ async function autoUpdate(auditSource) { // fall through to update apps if box update never started (failed ubuntu or avx check) } - const appUpdateInfo = _.omit(updateInfo, ['box']); - if (Object.keys(appUpdateInfo).length > 0) { - debug('autoUpdate: Starting app autoupdate: %j', Object.keys(appUpdateInfo)); - const [error] = await safe(apps.autoupdateApps(appUpdateInfo, AuditSource.CRON)); - if (error) debug(`autoUpdate: failed to app autoupdate: ${error.message}`); - } else { - debug('autoUpdate: no app auto updates available'); + const appUpdateInfoEntries = Object.entries(_.omit(updateInfo, ['box'])); + for (const [appId, appUpdateInfo] of appUpdateInfoEntries) { + const [getError, app] = await safe(apps.get(appId)); + if (getError || !app) { + debug(`autoUpdate: error getting ${appId}: ${getError?.message || 'no such app'}`); + continue; + } + + if (!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 ${appUpdateInfo.manifest.version}`, + `Changelog:\n${appUpdateInfo.manifest.changelog}\n`, { context: app.id }); + continue; + } + + const data = { + manifest: appUpdateInfo.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}`); } }