diff --git a/CHANGES b/CHANGES index 7a989fb3d..619f5c975 100644 --- a/CHANGES +++ b/CHANGES @@ -3169,3 +3169,6 @@ * backupintegrity: add percent progress * apps: fix acl display +[9.2.0] +* services: lazy start services / on demand services + diff --git a/src/apps.js b/src/apps.js index e410a1af8..f4865c490 100644 --- a/src/apps.js +++ b/src/apps.js @@ -1055,7 +1055,6 @@ async function onTaskFinished(error, appId, installationState, taskId, auditSour const app = await get(appId); const task = await tasks.get(taskId); - if (!app || !task) return; switch (installationState) { case ISTATE_PENDING_DATA_DIR_MIGRATION: @@ -1070,6 +1069,10 @@ async function onTaskFinished(error, appId, installationState, taskId, auditSour break; } } + + // this can race with an new install task but very unlikely + debug(`onTaskFinished: checking to stop unused services. hasPending: ${appTaskManager.hasPendingTasks()}`) + if (!appTaskManager.hasPendingTasks()) safe(services.stopUnusedServices(), { debug }); } async function getCount() { diff --git a/src/apptask.js b/src/apptask.js index 9db41f171..a001858ae 100644 --- a/src/apptask.js +++ b/src/apptask.js @@ -406,13 +406,10 @@ async function installCommand(app, args, progressCallback) { 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; + const addonsToRemove = _.omit(oldManifest.addons, Object.keys(app.manifest.addons)); + await services.teardownAddons(app, addonsToRemove); } - await services.teardownAddons(app, addonsToRemove); if (!restoreConfig || restoreConfig.remotePath || restoreConfig.backupId) { // install/import/restore but not in-place import should delete data dir await deleteAppDir(app, { removeDirectory: false }); // do not remove any symlinked appdata dir diff --git a/src/apptaskmanager.js b/src/apptaskmanager.js index f20d10162..b0df2158d 100644 --- a/src/apptaskmanager.js +++ b/src/apptaskmanager.js @@ -47,11 +47,13 @@ async function drain() { scheduler.suspendAppJobs(appId); // background + let taskError = null, taskResult = null; tasks.startTask(taskId, Object.assign(options, { logFile })) - .then(async (result) => await safe(onFinished(null, result), { debug })) - .catch(async (error) => await safe(onFinished(error), { debug })) + .then((result) => { taskResult = result; }) + .catch((error) => { taskError = error; }) .finally(async () => { delete gActiveTasks[appId]; + await safe(onFinished(taskError, taskResult), { debug }); // hasPendingTasks() can now return false await locks.release(`${locks.TYPE_APP_TASK_PREFIX}${appId}`); await locks.releaseByTaskId(taskId); scheduler.resumeAppJobs(appId); @@ -89,7 +91,12 @@ function scheduleTask(appId, taskId, options, onFinished) { if (gStarted && !gDrainTimerId) gDrainTimerId = setTimeout(drain, DRAIN_TIMER_SECS); } +function hasPendingTasks() { + return Object.keys(gActiveTasks).length > 0 || gPendingTasks.length > 0; +} + export default { start, - scheduleTask + scheduleTask, + hasPendingTasks }; diff --git a/src/platform.js b/src/platform.js index a2deddd56..9a05960c3 100644 --- a/src/platform.js +++ b/src/platform.js @@ -101,6 +101,9 @@ async function onInfraReady(infraChanged) { if (infraChanged) await safe(pruneVolumes(), { debug }); // ignore error await apps.schedulePendingTasks(AuditSource.PLATFORM); await appTaskManager.start(); + + // only prune services on infra change (which starts services for upgrade) + if (infraChanged) safe(services.stopUnusedServices(), { debug }); } async function startInfra(restoreOptions) { diff --git a/src/services.js b/src/services.js index 7aa3c4b74..274b3da40 100644 --- a/src/services.js +++ b/src/services.js @@ -41,6 +41,7 @@ const SERVICE_STATUS_DISABLED = 'disabled'; const NOOP = async function (/*app, options*/) {}; +const LAZY_SERVICES = ['mysql', 'postgresql', 'mongodb', 'turn']; const RMADDONDIR_CMD = path.join(import.meta.dirname, 'scripts/rmaddondir.sh'); const RESTART_SERVICE_CMD = path.join(import.meta.dirname, 'scripts/restartservice.sh'); const CLEARVOLUME_CMD = path.join(import.meta.dirname, 'scripts/clearvolume.sh'); @@ -199,6 +200,43 @@ async function waitForContainer(containerName, tokenEnvName) { }); } +async function ensureServiceRunning(serviceName) { + assert.strictEqual(typeof serviceName, 'string'); + + const tokenEnvNames = { + mysql: 'CLOUDRON_MYSQL_TOKEN', + postgresql: 'CLOUDRON_POSTGRESQL_TOKEN', + mongodb: 'CLOUDRON_MONGODB_TOKEN', + }; + + const [error, container] = await safe(docker.inspect(serviceName)); + if (error) throw new BoxError(BoxError.ADDONS_ERROR, `${serviceName} container not found`); + if (container.State?.Running) return; + + debug(`ensureServiceRunning: starting ${serviceName}`); + await docker.startContainer(serviceName); + + if (tokenEnvNames[serviceName]) await waitForContainer(serviceName, tokenEnvNames[serviceName]); +} + +async function stopUnusedServices() { + const allApps = await apps.list(); + const usedAddons = new Set(); + for (const app of allApps) { + for (const addon of Object.keys(app.manifest.addons || {})) { + usedAddons.add(addon); + } + } + + debug(`stopUnusedServices: used addons - ${[...usedAddons]}`); + + for (const name of LAZY_SERVICES) { + if (usedAddons.has(name)) continue; + debug(`stopUnusedServices: stopping ${name} (no apps use it)`); + await safe(docker.stopContainer(name), { debug }); + } +} + async function exportDatabase(addon) { assert.strictEqual(typeof addon, 'string'); @@ -445,6 +483,8 @@ async function setupTurn(app, options) { const disabled = app.manifest.addons.turn.optional && !app.enableTurn; if (disabled) return await addonConfigs.unset(app.id, 'turn'); + await ensureServiceRunning('turn'); + const turnSecret = await blobs.getString(blobs.ADDON_TURN_SECRET); if (!turnSecret) throw new BoxError(BoxError.ADDONS_ERROR, 'Turn secret is missing'); @@ -688,6 +728,8 @@ async function setupMySql(app, options) { debug('Setting up mysql'); + await ensureServiceRunning('mysql'); + const existingPassword = await addonConfigs.getByName(app.id, 'mysql', '%MYSQL_PASSWORD'); const tmp = mysqlDatabaseName(app.id); @@ -855,6 +897,8 @@ async function setupPostgreSql(app, options) { debug('Setting up postgresql'); + await ensureServiceRunning('postgresql'); + const { database, username } = postgreSqlNames(app.id); const existingPassword = await addonConfigs.getByName(app.id, 'postgresql', '%POSTGRESQL_PASSWORD'); @@ -1010,6 +1054,8 @@ async function setupMongoDb(app, options) { debug('Setting up mongodb'); + await ensureServiceRunning('mongodb'); + if (!await hasAVX()) throw new BoxError(BoxError.ADDONS_ERROR, 'Error setting up MongoDB. CPU has no AVX support'); const existingPassword = await addonConfigs.getByName(app.id, 'mongodb', '%MONGODB_PASSWORD'); @@ -1686,6 +1732,14 @@ async function applyMemoryLimit(id) { throw new BoxError(BoxError.NOT_FOUND, 'No such service'); } + if (LAZY_SERVICES.includes(name)) { + const [error, container] = await safe(docker.inspect(containerName)); + if (error || !container.State?.Running) { + debug(`applyMemoryLimit: skipping ${containerName} (not running)`); + return; + } + } + debug(`applyMemoryLimit: ${containerName} ${JSON.stringify(serviceConfig)}`); await docker.update(containerName, memoryLimit); @@ -2270,6 +2324,7 @@ export default { stopAppServices, startServices, + stopUnusedServices, applyServiceLimits, moveDataDir, // localstorage specific command