861 lines
37 KiB
JavaScript
861 lines
37 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
'use strict';
|
|
|
|
exports = module.exports = {
|
|
run,
|
|
|
|
// exported for testing
|
|
_createAppDir: createAppDir,
|
|
_deleteAppDir: deleteAppDir,
|
|
_verifyManifest: verifyManifest,
|
|
};
|
|
|
|
const apps = require('./apps.js'),
|
|
appstore = require('./appstore.js'),
|
|
assert = require('assert'),
|
|
AuditSource = require('./auditsource.js'),
|
|
backups = require('./backups.js'),
|
|
backuptask = require('./backuptask.js'),
|
|
BoxError = require('./boxerror.js'),
|
|
constants = require('./constants.js'),
|
|
debug = require('debug')('box:apptask'),
|
|
df = require('./df.js'),
|
|
dns = require('./dns.js'),
|
|
docker = require('./docker.js'),
|
|
ejs = require('ejs'),
|
|
fs = require('fs'),
|
|
iputils = require('./iputils.js'),
|
|
manifestFormat = require('cloudron-manifestformat'),
|
|
mounts = require('./mounts.js'),
|
|
os = require('os'),
|
|
path = require('path'),
|
|
paths = require('./paths.js'),
|
|
promiseRetry = require('./promise-retry.js'),
|
|
reverseProxy = require('./reverseproxy.js'),
|
|
safe = require('safetydance'),
|
|
services = require('./services.js'),
|
|
shell = require('./shell.js'),
|
|
_ = require('underscore');
|
|
|
|
const MV_VOLUME_CMD = path.join(__dirname, 'scripts/mvvolume.sh'),
|
|
LOGROTATE_CONFIG_EJS = fs.readFileSync(__dirname + '/logrotate.ejs', { encoding: 'utf8' }),
|
|
CONFIGURE_LOGROTATE_CMD = path.join(__dirname, 'scripts/configurelogrotate.sh');
|
|
|
|
function makeTaskError(error, app) {
|
|
assert(error instanceof BoxError);
|
|
assert.strictEqual(typeof app, 'object');
|
|
|
|
// track a few variables which helps 'repair' restart the task (see also scheduleTask in apps.js)
|
|
error.details.taskId = app.taskId;
|
|
error.details.installationState = app.installationState;
|
|
return error.toPlainObject();
|
|
}
|
|
|
|
// updates the app object and the database
|
|
async function updateApp(app, values) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof values, 'object');
|
|
|
|
await apps.update(app.id, values);
|
|
|
|
for (const value in values) {
|
|
app[value] = values[value];
|
|
}
|
|
}
|
|
|
|
async function allocateContainerIp(app) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
|
|
if (app.manifest.id === constants.PROXY_APP_APPSTORE_ID) return;
|
|
|
|
await promiseRetry({ times: 10, interval: 0, debug }, async function () {
|
|
const iprange = iputils.intFromIp(constants.APPS_IPv4_END) - iputils.intFromIp(constants.APPS_IPv4_START);
|
|
const rnd = Math.floor(Math.random() * iprange);
|
|
const containerIp = iputils.ipFromInt(iputils.intFromIp(constants.APPS_IPv4_START) + rnd);
|
|
await updateApp(app, { containerIp });
|
|
});
|
|
}
|
|
|
|
async function createContainer(app) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert(!app.containerId); // otherwise, it will trigger volumeFrom
|
|
|
|
if (app.manifest.id === constants.PROXY_APP_APPSTORE_ID) return;
|
|
|
|
debug('createContainer: creating container');
|
|
|
|
const container = await docker.createContainer(app);
|
|
|
|
await updateApp(app, { containerId: container.id });
|
|
|
|
// re-generate configs that rely on container id
|
|
await addLogrotateConfig(app);
|
|
}
|
|
|
|
async function deleteContainers(app, options) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof options, 'object');
|
|
|
|
debug('deleteContainer: deleting app containers (app, scheduler)');
|
|
|
|
// remove configs that rely on container id
|
|
await removeLogrotateConfig(app);
|
|
await docker.stopContainers(app.id);
|
|
await docker.deleteContainers(app.id, options);
|
|
await updateApp(app, { containerId: null });
|
|
}
|
|
|
|
async function createAppDir(app) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
|
|
// we have to create app dir regardless of localstorage addon. this dir is used as a temp space for backup dumps
|
|
const appDir = path.join(paths.APPS_DATA_DIR, app.id);
|
|
const [error] = await safe(fs.promises.mkdir(appDir, { recursive: true }));
|
|
if (error) throw new BoxError(BoxError.FS_ERROR, `Error creating directory: ${error.message}`, { appDir });
|
|
}
|
|
|
|
async function deleteAppDir(app, options) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof options, 'object');
|
|
|
|
const appDataDir = path.join(paths.APPS_DATA_DIR, app.id);
|
|
|
|
// resolve any symlinked data dir
|
|
const stat = safe.fs.lstatSync(appDataDir);
|
|
if (!stat) return;
|
|
|
|
const resolvedAppDataDir = stat.isSymbolicLink() ? safe.fs.readlinkSync(appDataDir) : appDataDir;
|
|
|
|
if (safe.fs.existsSync(resolvedAppDataDir)) {
|
|
const entries = safe.fs.readdirSync(resolvedAppDataDir);
|
|
if (!entries) throw new BoxError(BoxError.FS_ERROR, `Error listing ${resolvedAppDataDir}: ${safe.error.message}`);
|
|
|
|
// remove only files. directories inside app dir are currently volumes managed by the addons
|
|
// we cannot delete those dirs anyway because of perms
|
|
entries.forEach(function (entry) {
|
|
const stat = safe.fs.statSync(path.join(resolvedAppDataDir, entry));
|
|
if (stat && !stat.isDirectory()) safe.fs.unlinkSync(path.join(resolvedAppDataDir, entry));
|
|
});
|
|
}
|
|
|
|
// if this fails, it's probably because the localstorage/redis addons have not cleaned up properly
|
|
if (options.removeDirectory) {
|
|
if (stat.isSymbolicLink()) {
|
|
if (!safe.fs.unlinkSync(appDataDir)) {
|
|
if (safe.error.code !== 'ENOENT') throw new BoxError(BoxError.FS_ERROR, `Error unlinking dir ${appDataDir} : ${safe.error.message}`);
|
|
}
|
|
} else {
|
|
if (!safe.fs.rmSync(appDataDir, { recursive: true })) {
|
|
if (safe.error.code !== 'ENOENT') throw new BoxError(BoxError.FS_ERROR, `Error removing dir ${appDataDir} : ${safe.error.message}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function addLogrotateConfig(app) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
|
|
const result = await docker.inspect(app.containerId);
|
|
|
|
const runVolume = result.Mounts.find(function (mount) { return mount.Destination === '/run'; });
|
|
if (!runVolume) throw new BoxError(BoxError.DOCKER_ERROR, 'App does not have /run mounted');
|
|
|
|
// logrotate configs can have arbitrary commands, so the config files must be owned by root
|
|
const logrotateConf = ejs.render(LOGROTATE_CONFIG_EJS, { volumePath: runVolume.Source, appId: app.id });
|
|
const tmpFilePath = path.join(os.tmpdir(), app.id + '.logrotate');
|
|
|
|
safe.fs.writeFileSync(tmpFilePath, logrotateConf);
|
|
if (safe.error) throw new BoxError(BoxError.FS_ERROR, `Error writing logrotate config: ${safe.error.message}`);
|
|
|
|
const [error] = await safe(shell.promises.sudo('addLogrotateConfig', [ CONFIGURE_LOGROTATE_CMD, 'add', app.id, tmpFilePath ], {}));
|
|
if (error) throw new BoxError(BoxError.LOGROTATE_ERROR, `Error adding logrotate config: ${error.message}`);
|
|
}
|
|
|
|
async function removeLogrotateConfig(app) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
|
|
const [error] = await safe(shell.promises.sudo('removeLogrotateConfig', [ CONFIGURE_LOGROTATE_CMD, 'remove', app.id ], {}));
|
|
if (error) throw new BoxError(BoxError.LOGROTATE_ERROR, `Error removing logrotate config: ${error.message}`);
|
|
}
|
|
|
|
async function cleanupLogs(app) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
|
|
// note that redis container logs are cleaned up by the addon
|
|
const [error] = await safe(fs.promises.rm(path.join(paths.LOG_DIR, app.id), { force: true, recursive: true }));
|
|
if (error) debug('cleanupLogs: cannot cleanup logs: %o', error);
|
|
}
|
|
|
|
async function verifyManifest(manifest) {
|
|
assert.strictEqual(typeof manifest, 'object');
|
|
|
|
let error = manifestFormat.parse(manifest);
|
|
if (error) throw new BoxError(BoxError.BAD_FIELD, `Manifest error: ${error.message}`);
|
|
|
|
error = await apps.checkManifest(manifest);
|
|
if (error) throw new BoxError(BoxError.CONFLICT, `Manifest constraint check failed: ${error.message}`);
|
|
}
|
|
|
|
async function downloadIcon(app) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
|
|
// nothing to download if we dont have an appStoreId
|
|
if (!app.appStoreId) return;
|
|
|
|
debug(`downloadIcon: Downloading icon of ${app.appStoreId}@${app.manifest.version}`);
|
|
|
|
const appStoreIcon = await appstore.downloadIcon(app.appStoreId, app.manifest.version);
|
|
await updateApp(app, { appStoreIcon });
|
|
}
|
|
|
|
async function moveDataDir(app, targetVolumeId, targetVolumePrefix) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.ok(app.manifest.addons.localstorage, 'should have local storage addon');
|
|
assert(targetVolumeId === null || typeof targetVolumeId === 'string');
|
|
assert(targetVolumePrefix === null || typeof targetVolumePrefix === 'string');
|
|
|
|
const resolvedSourceDir = await apps.getStorageDir(app);
|
|
const resolvedTargetDir = await apps.getStorageDir(Object.assign({}, app, { storageVolumeId: targetVolumeId, storageVolumePrefix: targetVolumePrefix }));
|
|
|
|
debug(`moveDataDir: migrating data from ${resolvedSourceDir} to ${resolvedTargetDir}`);
|
|
|
|
if (resolvedSourceDir !== resolvedTargetDir) {
|
|
const [error] = await safe(shell.promises.sudo('moveDataDir', [ MV_VOLUME_CMD, resolvedSourceDir, resolvedTargetDir ], {}));
|
|
if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, `Error migrating data directory: ${error.message}`);
|
|
}
|
|
|
|
await updateApp(app, { storageVolumeId: targetVolumeId, storageVolumePrefix: targetVolumePrefix });
|
|
}
|
|
|
|
async function downloadImage(manifest) {
|
|
assert.strictEqual(typeof manifest, 'object');
|
|
|
|
// skip for relay app
|
|
if (manifest.id === constants.PROXY_APP_APPSTORE_ID) return;
|
|
|
|
const info = await docker.info();
|
|
const [dfError, diskUsage] = await safe(df.file(info.DockerRootDir));
|
|
if (dfError) throw new BoxError(BoxError.FS_ERROR, `Error getting file system info: ${dfError.message}`);
|
|
|
|
if (diskUsage.available < (1024*1024*1024)) throw new BoxError(BoxError.DOCKER_ERROR, 'Not enough disk space to pull docker image', { diskUsage: diskUsage, dockerRootDir: info.DockerRootDir });
|
|
|
|
await docker.downloadImage(manifest);
|
|
}
|
|
|
|
async function updateChecklist(app, newChecks) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof newChecks, 'object');
|
|
|
|
// add new checklist items depending on sso state
|
|
const checklist = app.checklist || {};
|
|
for (const k in newChecks) {
|
|
if (app.checklist[k]) continue;
|
|
|
|
const item = {
|
|
acknowledged: false,
|
|
sso: newChecks[k].sso,
|
|
appVersion: app.version,
|
|
message: newChecks[k].message,
|
|
changedAt: 0,
|
|
changedBy: null, // username from audit log
|
|
};
|
|
|
|
if (typeof item.sso === 'undefined') checklist[k] = item;
|
|
else if (item.sso && app.sso) checklist[k] = item;
|
|
else if (!item.sso && !app.sso) checklist[k] = item;
|
|
}
|
|
|
|
await updateApp(app, { checklist });
|
|
}
|
|
|
|
|
|
async function startApp(app) {
|
|
debug('startApp: starting container');
|
|
|
|
if (app.runState === apps.RSTATE_STOPPED) return;
|
|
|
|
// skip for relay app
|
|
if (app.manifest.id === constants.PROXY_APP_APPSTORE_ID) return;
|
|
|
|
await docker.startContainer(app.id);
|
|
}
|
|
|
|
async function install(app, args, progressCallback) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof args, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
const restoreConfig = args.restoreConfig; // has to be set when restoring
|
|
const overwriteDns = args.overwriteDns;
|
|
const skipDnsSetup = args.skipDnsSetup;
|
|
const oldManifest = args.oldManifest;
|
|
|
|
// this protects against the theoretical possibility of an app being marked for install/restore from
|
|
// a previous version of box code
|
|
await verifyManifest(app.manifest);
|
|
|
|
// teardown for re-installs
|
|
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
|
|
await reverseProxy.unconfigureApp(app);
|
|
await deleteContainers(app, { managedOnly: true });
|
|
|
|
// when restoring, app does not require these addons anymore. remove carefully to preserve the db passwords
|
|
let addonsToRemove;
|
|
if (oldManifest) {
|
|
addonsToRemove = _.omit(oldManifest.addons, Object.keys(app.manifest.addons));
|
|
} else {
|
|
addonsToRemove = app.manifest.addons;
|
|
}
|
|
await services.teardownAddons(app, addonsToRemove);
|
|
|
|
if (!restoreConfig || restoreConfig.remotePath) { // in-place import should not delete data dir
|
|
await deleteAppDir(app, { removeDirectory: false }); // do not remove any symlinked appdata dir
|
|
}
|
|
|
|
if (oldManifest && oldManifest.dockerImage !== app.manifest.dockerImage) {
|
|
await docker.deleteImage(oldManifest);
|
|
}
|
|
|
|
// allocating container ip here, lets the users "repair" an app if allocation fails at apps.add time
|
|
await allocateContainerIp(app);
|
|
|
|
await progressCallback({ percent: 20, message: 'Downloading icon' });
|
|
await downloadIcon(app);
|
|
|
|
await progressCallback({ percent: 25, message: 'Updating checklist' });
|
|
await updateChecklist(app, app.manifest.checklist || {});
|
|
|
|
if (!skipDnsSetup) {
|
|
await progressCallback({ percent: 30, message: 'Registering subdomains' });
|
|
|
|
await dns.registerLocations([ { subdomain: app.subdomain, domain: app.domain }].concat(app.secondaryDomains).concat(app.redirectDomains).concat(app.aliasDomains), { overwriteDns }, progressCallback);
|
|
}
|
|
|
|
await progressCallback({ percent: 40, message: 'Downloading image' });
|
|
await downloadImage(app.manifest);
|
|
|
|
await progressCallback({ percent: 50, message: 'Creating app data directory' });
|
|
await createAppDir(app);
|
|
|
|
if (!restoreConfig) { // install
|
|
await progressCallback({ percent: 60, message: 'Setting up addons' });
|
|
await services.setupAddons(app, app.manifest.addons);
|
|
} else if (app.installationState === apps.ISTATE_PENDING_IMPORT && !restoreConfig.remotePath) { // in-place import
|
|
await progressCallback({ percent: 60, message: 'Importing addons in-place' });
|
|
await services.setupAddons(app, app.manifest.addons);
|
|
await services.clearAddons(app, _.omit(app.manifest.addons, 'localstorage'));
|
|
await apps.loadConfig(app);
|
|
await services.restoreAddons(app, app.manifest.addons);
|
|
} else if (app.installationState === apps.ISTATE_PENDING_IMPORT) { // import
|
|
await progressCallback({ percent: 65, message: 'Downloading backup and restoring addons' });
|
|
await services.setupAddons(app, app.manifest.addons);
|
|
await services.clearAddons(app, app.manifest.addons);
|
|
const backupConfig = restoreConfig.backupConfig;
|
|
const mountObject = await backups.setupManagedStorage(backupConfig, `/mnt/appimport-${app.id}`);
|
|
if (mountObject) await progressCallback({ percent: 70, message: 'Setting up mount for importing' });
|
|
backupConfig.rootPath = backups.getRootPath(backupConfig, `/mnt/appimport-${app.id}`);
|
|
await backuptask.downloadApp(app, restoreConfig, (progress) => { progressCallback({ percent: 75, message: progress.message }); });
|
|
await apps.loadConfig(app);
|
|
if (mountObject) await mounts.removeMount(mountObject);
|
|
await progressCallback({ percent: 75, message: 'Restoring addons' });
|
|
await services.restoreAddons(app, app.manifest.addons);
|
|
} else { // clone and restore
|
|
await progressCallback({ percent: 65, message: 'Downloading backup and restoring addons' });
|
|
await services.setupAddons(app, app.manifest.addons);
|
|
await services.clearAddons(app, app.manifest.addons);
|
|
await backuptask.downloadApp(app, restoreConfig, (progress) => { progressCallback({ percent: 65, message: progress.message }); });
|
|
await progressCallback({ percent: 70, message: 'Restoring addons' });
|
|
await services.restoreAddons(app, app.manifest.addons);
|
|
}
|
|
|
|
await progressCallback({ percent: 80, message: 'Creating container' });
|
|
await createContainer(app);
|
|
|
|
await startApp(app);
|
|
|
|
if (!skipDnsSetup) {
|
|
await progressCallback({ percent: 85, message: 'Waiting for DNS propagation' });
|
|
await dns.waitForLocations([ { subdomain: app.subdomain, domain: app.domain }].concat(app.secondaryDomains).concat(app.redirectDomains).concat(app.aliasDomains), progressCallback);
|
|
}
|
|
|
|
await progressCallback({ percent: 95, message: 'Configuring reverse proxy' });
|
|
await reverseProxy.configureApp(app, AuditSource.APPTASK);
|
|
|
|
await progressCallback({ percent: 100, message: 'Done' });
|
|
await updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null });
|
|
}
|
|
|
|
async function backup(app, args, progressCallback) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof args, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
await progressCallback({ percent: 10, message: 'Backing up' });
|
|
const backupId = await backuptask.backupApp(app, { snapshotOnly: !!args.snapshotOnly }, (progress) => {
|
|
progressCallback({ percent: 30, message: progress.message });
|
|
});
|
|
|
|
await progressCallback({ percent: 100, message: 'Done' });
|
|
await updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null });
|
|
return backupId;
|
|
}
|
|
|
|
async function create(app, args, progressCallback) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof args, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
|
|
await deleteContainers(app, { managedOnly: true });
|
|
|
|
// FIXME: re-setup addons only because sendmail addon to re-inject env vars on mailboxName change
|
|
await progressCallback({ percent: 30, message: 'Setting up addons' });
|
|
await services.setupAddons(app, app.manifest.addons);
|
|
|
|
await progressCallback({ percent: 60, message: 'Creating container' });
|
|
await createContainer(app);
|
|
|
|
await startApp(app);
|
|
|
|
await progressCallback({ percent: 100, message: 'Done' });
|
|
await updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null });
|
|
}
|
|
|
|
async function changeLocation(app, args, progressCallback) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof args, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
const oldConfig = args.oldConfig;
|
|
const locationChanged = oldConfig.fqdn !== app.fqdn;
|
|
const skipDnsSetup = args.skipDnsSetup;
|
|
const overwriteDns = args.overwriteDns;
|
|
|
|
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
|
|
await reverseProxy.unconfigureApp(app);
|
|
await deleteContainers(app, { managedOnly: true });
|
|
|
|
// unregister old domains
|
|
let obsoleteDomains = [];
|
|
|
|
if (oldConfig.secondaryDomains) {
|
|
obsoleteDomains = obsoleteDomains.concat(oldConfig.secondaryDomains.filter(function (o) {
|
|
return !app.secondaryDomains.some(function (n) { return n.subdomain === o.subdomain && n.domain === o.domain; });
|
|
}));
|
|
}
|
|
|
|
if (oldConfig.redirectDomains) {
|
|
obsoleteDomains = obsoleteDomains.concat(oldConfig.redirectDomains.filter(function (o) {
|
|
return !app.redirectDomains.some(function (n) { return n.subdomain === o.subdomain && n.domain === o.domain; });
|
|
}));
|
|
}
|
|
|
|
if (oldConfig.aliasDomains) {
|
|
obsoleteDomains = obsoleteDomains.concat(oldConfig.aliasDomains.filter(function (o) {
|
|
return !app.aliasDomains.some(function (n) { return n.subdomain === o.subdomain && n.domain === o.domain; });
|
|
}));
|
|
}
|
|
|
|
if (locationChanged) obsoleteDomains.push({ subdomain: oldConfig.subdomain, domain: oldConfig.domain });
|
|
|
|
if (obsoleteDomains.length !== 0) await dns.unregisterLocations(obsoleteDomains, progressCallback);
|
|
|
|
// setup dns
|
|
if (!skipDnsSetup) {
|
|
await progressCallback({ percent: 30, message: 'Registering subdomains' });
|
|
await dns.registerLocations([ { subdomain: app.subdomain, domain: app.domain }].concat(app.secondaryDomains).concat(app.redirectDomains).concat(app.aliasDomains), { overwriteDns }, progressCallback);
|
|
}
|
|
|
|
// re-setup addons since they rely on the app's fqdn (e.g oauth)
|
|
await progressCallback({ percent: 50, message: 'Setting up addons' });
|
|
await services.setupAddons(app, app.manifest.addons);
|
|
|
|
await progressCallback({ percent: 60, message: 'Creating container' });
|
|
await createContainer(app);
|
|
|
|
await startApp(app);
|
|
|
|
if (!skipDnsSetup) {
|
|
await progressCallback({ percent: 80, message: 'Waiting for DNS propagation' });
|
|
await dns.waitForLocations([ { subdomain: app.subdomain, domain: app.domain }].concat(app.secondaryDomains).concat(app.redirectDomains).concat(app.aliasDomains), progressCallback);
|
|
}
|
|
|
|
await progressCallback({ percent: 90, message: 'Configuring reverse proxy' });
|
|
await reverseProxy.configureApp(app, AuditSource.APPTASK);
|
|
|
|
await progressCallback({ percent: 100, message: 'Done' });
|
|
await updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null });
|
|
}
|
|
|
|
async function changeServices(app, args, progressCallback) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof args, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
|
|
await deleteContainers(app, { managedOnly: true });
|
|
|
|
const unusedAddons = {};
|
|
if (app.manifest.addons?.turn && !app.enableTurn) unusedAddons.turn = app.manifest.addons.turn;
|
|
if (app.manifest.addons?.sendmail && !app.enableMailbox) unusedAddons.sendmail = app.manifest.addons.sendmail;
|
|
if (app.manifest.addons?.recvmail && !app.enableInbox) unusedAddons.recvmail = app.manifest.addons.recvmail;
|
|
if (app.manifest.addons?.redis && !app.enableRedis) unusedAddons.redis = app.manifest.addons.redis;
|
|
|
|
await progressCallback({ percent: 20, message: 'Removing unused addons' });
|
|
await services.teardownAddons(app, unusedAddons);
|
|
|
|
await progressCallback({ percent: 40, message: 'Setting up addons' });
|
|
await services.setupAddons(app, app.manifest.addons);
|
|
|
|
await progressCallback({ percent: 60, message: 'Creating container' });
|
|
await createContainer(app);
|
|
|
|
await startApp(app);
|
|
|
|
await progressCallback({ percent: 100, message: 'Done' });
|
|
await updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null });
|
|
}
|
|
|
|
async function migrateDataDir(app, args, progressCallback) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof args, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
const { newStorageVolumeId, newStorageVolumePrefix } = args;
|
|
assert(newStorageVolumeId === null || typeof newStorageVolumeId === 'string');
|
|
assert(newStorageVolumePrefix === null || typeof newStorageVolumePrefix === 'string');
|
|
|
|
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
|
|
await deleteContainers(app, { managedOnly: true });
|
|
|
|
await progressCallback({ percent: 45, message: 'Ensuring app data directory' });
|
|
await createAppDir(app);
|
|
|
|
// re-setup addons since this creates the localStorage destination
|
|
await progressCallback({ percent: 50, message: 'Setting up addons' });
|
|
await services.setupAddons(Object.assign({}, app, { storageVolumeId: newStorageVolumeId, storageVolumePrefix: newStorageVolumePrefix }), app.manifest.addons);
|
|
|
|
if (app.manifest.addons?.localstorage) {
|
|
await progressCallback({ percent: 60, message: 'Moving data dir' });
|
|
await moveDataDir(app, newStorageVolumeId, newStorageVolumePrefix);
|
|
}
|
|
|
|
await progressCallback({ percent: 90, message: 'Creating container' });
|
|
await createContainer(app);
|
|
|
|
await startApp(app);
|
|
|
|
await progressCallback({ percent: 100, message: 'Done' });
|
|
await updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null });
|
|
}
|
|
|
|
// configure is called for an infra update and repair to re-create container, reverseproxy config. it's all "local"
|
|
async function configure(app, args, progressCallback) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof args, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
|
|
await reverseProxy.unconfigureApp(app);
|
|
await deleteContainers(app, { managedOnly: true });
|
|
|
|
await progressCallback({ percent: 20, message: 'Downloading icon' });
|
|
await downloadIcon(app);
|
|
|
|
await progressCallback({ percent: 40, message: 'Downloading image' });
|
|
await downloadImage(app.manifest);
|
|
|
|
await progressCallback({ percent: 45, message: 'Ensuring app data directory' });
|
|
await createAppDir(app);
|
|
|
|
// re-setup addons since they rely on the app's fqdn (e.g oauth)
|
|
await progressCallback({ percent: 50, message: 'Setting up addons' });
|
|
await services.setupAddons(app, app.manifest.addons);
|
|
|
|
await progressCallback({ percent: 60, message: 'Creating container' });
|
|
await createContainer(app);
|
|
|
|
await startApp(app);
|
|
|
|
await progressCallback({ percent: 90, message: 'Configuring reverse proxy' });
|
|
await reverseProxy.configureApp(app, AuditSource.APPTASK);
|
|
|
|
await progressCallback({ percent: 100, message: 'Done' });
|
|
await updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null });
|
|
}
|
|
|
|
async function update(app, args, progressCallback) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof args, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
const updateConfig = args.updateConfig;
|
|
|
|
// app does not want these addons anymore
|
|
// FIXME: this does not handle option changes (like multipleDatabases)
|
|
const unusedAddons = _.omit(app.manifest.addons, Object.keys(updateConfig.manifest.addons));
|
|
const httpPortChanged = app.manifest.httpPort !== updateConfig.manifest.httpPort;
|
|
const proxyAuthChanged = !_.isEqual(app.manifest.addons?.proxyAuth, updateConfig.manifest.addons?.proxyAuth);
|
|
|
|
// this protects against the theoretical possibility of an app being marked for update from
|
|
// a previous version of box code
|
|
await progressCallback({ percent: 5, message: 'Verify manifest' });
|
|
await verifyManifest(updateConfig.manifest);
|
|
|
|
if (!updateConfig.skipBackup) {
|
|
await progressCallback({ percent: 15, message: 'Backing up app' });
|
|
// preserve update backups for 3 weeks
|
|
const [error] = await safe(backuptask.backupApp(app, { preserveSecs: 3*7*24*60*60 }, (progress) => {
|
|
progressCallback({ percent: 15, message: `Backup - ${progress.message}` });
|
|
}));
|
|
if (error) {
|
|
error.backupError = true;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
await progressCallback({ percent: 20, message: 'Updating checklist' });
|
|
await updateChecklist(app, app.manifest.checklist || {});
|
|
|
|
// download new image before app is stopped. this is so we can reduce downtime
|
|
// and also not remove the 'common' layers when the old image is deleted
|
|
await progressCallback({ percent: 25, message: 'Downloading image' });
|
|
await downloadImage(updateConfig.manifest);
|
|
|
|
// note: we cleanup first and then backup. this is done so that the app is not running should backup fail
|
|
// we cannot easily 'recover' from backup failures because we have to revert manfest and portBindings
|
|
await progressCallback({ percent: 35, message: 'Cleaning up old install' });
|
|
await deleteContainers(app, { managedOnly: true });
|
|
if (app.manifest.dockerImage !== updateConfig.manifest.dockerImage) await docker.deleteImage(app.manifest);
|
|
|
|
// only delete unused addons after backup
|
|
await services.teardownAddons(app, unusedAddons);
|
|
if (Object.keys(unusedAddons).includes('localstorage')) await updateApp(app, { storageVolumeId: null, storageVolumePrefix: null }); // lose reference
|
|
|
|
// free unused ports. this is done after backup, so the app object is not in some inconsistent state should backup fail
|
|
const newTcpPorts = updateConfig.manifest.tcpPorts || {};
|
|
const newUdpPorts = updateConfig.manifest.udpPorts || {};
|
|
const portBindings = { ...app.portBindings };
|
|
|
|
for (const portName of Object.keys(portBindings)) {
|
|
if (newTcpPorts[portName] || newUdpPorts[portName]) continue; // port still in use
|
|
delete portBindings[portName];
|
|
}
|
|
|
|
// clear aliasDomains if needed based multiDomain change
|
|
const aliasDomains = app.manifest.multiDomain && !updateConfig.manifest.multiDomain ? [] : app.aliasDomains;
|
|
|
|
// clear unused secondaryDomains
|
|
const secondaryDomains = [ ...app.secondaryDomains ];
|
|
const newHttpPorts = updateConfig.manifest.httpPorts || {};
|
|
for (let i = secondaryDomains.length-1; i >= 0; i--) {
|
|
const { environmentVariable } = secondaryDomains[i];
|
|
if (environmentVariable in newHttpPorts) continue; // domain still in use
|
|
secondaryDomains.splice(i, 1); // remove domain
|
|
}
|
|
|
|
const values = {
|
|
manifest: updateConfig.manifest,
|
|
portBindings,
|
|
// all domains have to be updated together
|
|
subdomain: app.subdomain,
|
|
domain: app.domain,
|
|
aliasDomains,
|
|
secondaryDomains,
|
|
redirectDomains: app.redirectDomains
|
|
};
|
|
if ('memoryLimit' in updateConfig) values.memoryLimit = updateConfig.memoryLimit;
|
|
if ('appStoreId' in updateConfig) values.appStoreId = updateConfig.appStoreId;
|
|
|
|
await updateApp(app, values); // switch over to the new config
|
|
|
|
await progressCallback({ percent: 45, message: 'Downloading icon' });
|
|
await downloadIcon(app);
|
|
|
|
await progressCallback({ percent: 60, message: 'Updating addons' });
|
|
await services.setupAddons(app, updateConfig.manifest.addons);
|
|
|
|
await progressCallback({ percent: 70, message: 'Creating container' });
|
|
await createContainer(app);
|
|
|
|
await startApp(app);
|
|
|
|
await progressCallback({ percent: 90, message: 'Configuring reverse proxy' });
|
|
if (proxyAuthChanged || httpPortChanged) {
|
|
await reverseProxy.configureApp(app, AuditSource.APPTASK);
|
|
}
|
|
|
|
await progressCallback({ percent: 100, message: 'Done' });
|
|
await updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null, updateTime: new Date() });
|
|
}
|
|
|
|
async function start(app, args, progressCallback) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof args, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
await progressCallback({ percent: 10, message: 'Starting app services' });
|
|
await services.startAppServices(app);
|
|
|
|
if (app.manifest.id !== constants.PROXY_APP_APPSTORE_ID) {
|
|
await progressCallback({ percent: 35, message: 'Starting container' });
|
|
await docker.startContainer(app.id);
|
|
}
|
|
|
|
// stopped apps do not renew certs. currently, we don't do DNS to not overwrite existing user settings
|
|
await progressCallback({ percent: 80, message: 'Configuring reverse proxy' });
|
|
await reverseProxy.configureApp(app, AuditSource.APPTASK);
|
|
|
|
await progressCallback({ percent: 100, message: 'Done' });
|
|
await updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null });
|
|
}
|
|
|
|
async function stop(app, args, progressCallback) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof args, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
await progressCallback({ percent: 20, message: 'Stopping container' });
|
|
await reverseProxy.unconfigureApp(app); // removing nginx configs also means that we can auto-cleanup old certs since they are not referenced
|
|
await docker.stopContainers(app.id);
|
|
|
|
await progressCallback({ percent: 50, message: 'Stopping app services' });
|
|
await services.stopAppServices(app);
|
|
|
|
await progressCallback({ percent: 100, message: 'Done' });
|
|
await updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null });
|
|
}
|
|
|
|
async function restart(app, args, progressCallback) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof args, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
if (app.manifest.id !== constants.PROXY_APP_APPSTORE_ID) {
|
|
await progressCallback({ percent: 10, message: 'Starting app services' });
|
|
await services.startAppServices(app);
|
|
|
|
await progressCallback({ percent: 20, message: 'Restarting container' });
|
|
await docker.restartContainer(app.id);
|
|
}
|
|
|
|
// stopped apps do not renew certs. currently, we don't do DNS to not overwrite existing user settings
|
|
await progressCallback({ percent: 80, message: 'Configuring reverse proxy' });
|
|
await reverseProxy.configureApp(app, AuditSource.APPTASK);
|
|
|
|
await progressCallback({ percent: 100, message: 'Done' });
|
|
await updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null });
|
|
}
|
|
|
|
async function uninstall(app, args, progressCallback) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof args, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
await progressCallback({ percent: 20, message: 'Deleting container' });
|
|
await reverseProxy.unconfigureApp(app);
|
|
await deleteContainers(app, {});
|
|
|
|
await progressCallback({ percent: 30, message: 'Teardown addons' });
|
|
await services.teardownAddons(app, app.manifest.addons);
|
|
|
|
await progressCallback({ percent: 40, message: 'Cleanup file manager' });
|
|
|
|
await progressCallback({ percent: 50, message: 'Deleting app data directory' });
|
|
await deleteAppDir(app, { removeDirectory: true });
|
|
|
|
await progressCallback({ percent: 60, message: 'Deleting image' });
|
|
await docker.deleteImage(app.manifest);
|
|
|
|
await progressCallback({ percent: 70, message: 'Unregistering domains' });
|
|
await dns.unregisterLocations([ { subdomain: app.subdomain, domain: app.domain } ].concat(app.secondaryDomains).concat(app.redirectDomains).concat(app.aliasDomains), progressCallback);
|
|
|
|
await progressCallback({ percent: 90, message: 'Cleanup logs' });
|
|
await cleanupLogs(app);
|
|
|
|
await progressCallback({ percent: 95, message: 'Remove app from database' });
|
|
await apps.del(app.id);
|
|
}
|
|
|
|
async function run(appId, args, progressCallback) {
|
|
assert.strictEqual(typeof appId, 'string');
|
|
assert.strictEqual(typeof args, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
const app = await apps.get(appId);
|
|
|
|
debug(`run: startTask installationState: ${app.installationState} runState: ${app.runState}`);
|
|
|
|
let cmd;
|
|
|
|
switch (app.installationState) {
|
|
case apps.ISTATE_PENDING_INSTALL:
|
|
case apps.ISTATE_PENDING_CLONE:
|
|
case apps.ISTATE_PENDING_RESTORE:
|
|
case apps.ISTATE_PENDING_IMPORT:
|
|
cmd = install(app, args, progressCallback);
|
|
break;
|
|
case apps.ISTATE_PENDING_CONFIGURE:
|
|
cmd = configure(app, args, progressCallback);
|
|
break;
|
|
case apps.ISTATE_PENDING_RECREATE_CONTAINER:
|
|
case apps.ISTATE_PENDING_RESIZE:
|
|
case apps.ISTATE_PENDING_DEBUG:
|
|
cmd = create(app, args, progressCallback);
|
|
break;
|
|
case apps.ISTATE_PENDING_LOCATION_CHANGE:
|
|
cmd = changeLocation(app, args, progressCallback);
|
|
break;
|
|
case apps.ISTATE_PENDING_SERVICES_CHANGE:
|
|
cmd = changeServices(app, args, progressCallback);
|
|
break;
|
|
case apps.ISTATE_PENDING_DATA_DIR_MIGRATION:
|
|
cmd = migrateDataDir(app, args, progressCallback);
|
|
break;
|
|
case apps.ISTATE_PENDING_UNINSTALL:
|
|
cmd = uninstall(app, args, progressCallback);
|
|
break;
|
|
case apps.ISTATE_PENDING_UPDATE:
|
|
cmd = update(app, args, progressCallback);
|
|
break;
|
|
case apps.ISTATE_PENDING_BACKUP:
|
|
cmd = backup(app, args, progressCallback);
|
|
break;
|
|
case apps.ISTATE_PENDING_START:
|
|
cmd = start(app, args, progressCallback);
|
|
break;
|
|
case apps.ISTATE_PENDING_STOP:
|
|
cmd = stop(app, args, progressCallback);
|
|
break;
|
|
case apps.ISTATE_PENDING_RESTART:
|
|
cmd = restart(app, args, progressCallback);
|
|
break;
|
|
case apps.ISTATE_INSTALLED: // can only happen when we have a bug in our code while testing/development
|
|
cmd = updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null });
|
|
break;
|
|
default:
|
|
debug('run: apptask launched with invalid command');
|
|
throw new BoxError(BoxError.INTERNAL_ERROR, 'Unknown install command in apptask:' + app.installationState);
|
|
}
|
|
|
|
const [error, result] = await safe(cmd); // only some commands like backup return a result
|
|
if (error) {
|
|
debug(`run: app error for state ${app.installationState}: %o`, error);
|
|
|
|
if (app.installationState === apps.ISTATE_PENDING_BACKUP) {
|
|
// return to installed state intentionally. the error is stashed only in the task and not the app (the UI shows error state otherwise)
|
|
await safe(updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null }), { debug });
|
|
} else if (app.installationState === apps.ISTATE_PENDING_UPDATE && error.backupError) {
|
|
debug('run: update aborted because backup failed');
|
|
await safe(updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null }, { debug }));
|
|
} else {
|
|
await safe(updateApp(app, { installationState: apps.ISTATE_ERROR, error: makeTaskError(error, app) }), { debug });
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
|
|
return result || null;
|
|
}
|