diff --git a/package-lock.json b/package-lock.json index fbb8af9f..427122d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -403,6 +403,24 @@ "@types/chai": "*" } }, + "@types/chai-like": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@types/chai-like/-/chai-like-1.1.0.tgz", + "integrity": "sha512-PQ8Ejnng+k377MZv+PLV2q8J4vzDZup95kZv7WhmHQ9He8haZqBz4b1fYY9uZQfbq+Oaz04m2/9Ffl5BGbFImg==", + "dev": true, + "requires": { + "@types/chai": "*" + } + }, + "@types/chai-things": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/@types/chai-things/-/chai-things-0.0.34.tgz", + "integrity": "sha512-vcpFz782jq7FpEnE9Yq0cfgP8NwRPQgQ2271Q+7hEldOHttTQaYuVj0S9ViQXkM+sYSgUh/2OF5vTq8iei8Ljg==", + "dev": true, + "requires": { + "@types/chai": "*" + } + }, "@types/color-name": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", @@ -2139,6 +2157,16 @@ "chai": "^3.5.0" } }, + "chai-like": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/chai-like/-/chai-like-1.1.1.tgz", + "integrity": "sha512-VKa9z/SnhXhkT1zIjtPACFWSoWsqVoaz1Vg+ecrKo5DCKVlgL30F/pEyEvXPBOVwCgLZcWUleCM/C1okaKdTTA==" + }, + "chai-things": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chai-things/-/chai-things-0.2.0.tgz", + "integrity": "sha1-xVEoN4+bs5nplPAAUhUZhO1uvnA=" + }, "chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", diff --git a/package.json b/package.json index 0c0ce1f1..bf1bccf0 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,8 @@ }, "private": true, "dependencies": { + "chai-like": "^1.1.1", + "chai-things": "^0.2.0", "dbus": "^1.0.7", "mdns-resolver": "^1.0.0", "os-utils": "0.0.14", @@ -44,6 +46,8 @@ "@types/bluebird": "^3.5.32", "@types/chai": "^4.2.12", "@types/chai-as-promised": "^7.1.3", + "@types/chai-like": "^1.1.0", + "@types/chai-things": "0.0.34", "@types/common-tags": "^1.8.0", "@types/copy-webpack-plugin": "^6.0.0", "@types/dbus": "^1.0.0", diff --git a/src/api-binder.ts b/src/api-binder.ts index 9fb0100b..83015307 100644 --- a/src/api-binder.ts +++ b/src/api-binder.ts @@ -237,7 +237,7 @@ export async function patchDevice( } export async function provisionDependentDevice( - device: Device, + device: Partial, ): Promise { const conf = await config.getMany([ 'unmanaged', @@ -298,13 +298,13 @@ export async function fetchDeviceTags(): Promise { if (deviceId == null) { throw new Error('Attempt to retrieve device tags before provision'); } - const tags = (await balenaApi.get({ + const tags = await balenaApi.get({ resource: 'device_tag', options: { $select: ['id', 'tag_key', 'value'], $filter: { device: deviceId }, }, - })); + }); return tags.map((tag) => { // Do some type safe decoding and throw if we get an unexpected value @@ -565,7 +565,7 @@ async function reportInitialEnv( const defaultConfig = deviceConfig.getDefaults(); - const currentState = await deviceState.getCurrentForComparison(); + const currentState = await deviceState.getCurrentState(); const targetConfig = await deviceConfig.formatConfigKeys( targetConfigUnformatted, ); @@ -703,8 +703,12 @@ async function reportInitialName( }, }); } catch (err) { - log.error("Unable to report initial device name to API"); - logger.logSystemMessage("Unable to report initial device name to API", err, 'reportInitialNameError'); + log.error('Unable to report initial device name to API'); + logger.logSystemMessage( + 'Unable to report initial device name to API', + err, + 'reportInitialNameError', + ); } } diff --git a/src/application-manager.d.ts b/src/application-manager.d.ts deleted file mode 100644 index 4aa0b491..00000000 --- a/src/application-manager.d.ts +++ /dev/null @@ -1,98 +0,0 @@ -import * as Bluebird from 'bluebird'; -import { EventEmitter } from 'events'; -import { Router } from 'express'; -import Knex = require('knex'); - -import { ServiceAction } from './device-api/common'; -import { DeviceStatus, InstancedAppState } from './types/state'; - -import type { Image } from './compose/images'; -import * as deviceState from './device-state'; -import * as apiBinder from './api-binder'; - -import * as config from './config'; - -import { - CompositionStep, - CompositionStepAction, -} from './compose/composition-steps'; -import Network from './compose/network'; -import Service from './compose/service'; -import Volume from './compose/volume'; - -declare interface Options { - force?: boolean; - running?: boolean; - skipLock?: boolean; -} - -// TODO: This needs to be moved to the correct module's typings -declare interface Application { - services: Service[]; -} - -// This is a non-exhaustive typing for ApplicationManager to avoid -// having to recode the entire class (and all requirements in TS). -class ApplicationManager extends EventEmitter { - // These probably could be typed, but the types are so messy that we're - // best just waiting for the relevant module to be recoded in typescript. - // At least any types we can be sure of then. - // - // TODO: When the module which is/declares these fields is converted to - // typecript, type the following - public _lockingIfNecessary: any; - - public proxyvisor: any; - public timeSpentFetching: number; - public fetchesInProgress: number; - - public validActions: string[]; - - public router: Router; - - public constructor(); - - public init(): Promise; - - public getCurrentApp(appId: number): Promise; - - // TODO: This actually returns an object, but we don't need the values just yet - public setTargetVolatileForService(serviceId: number, opts: Options): void; - - public executeStepAction( - serviceAction: ServiceAction, - opts: Options, - ): Bluebird; - - public setTarget( - local: any, - dependent: any, - source: string, - transaction: Knex.Transaction, - ): Promise; - - public getStatus(): Promise<{ - local: DeviceStatus.local.apps; - dependent: DeviceStatus.dependent; - commit: DeviceStatus.commit; - }>; - // The return type is incompleted - public getTargetApps(): Promise; - public stopAll(opts: { force?: boolean; skipLock?: boolean }): Promise; - - public serviceNameFromId(serviceId: number): Promise; - public imageForService(svc: any): Image; - public getDependentTargets(): Promise; - public getCurrentForComparison(): Promise; - public getDependentState(): Promise; - public getExtraStateForComparison(current: any, target: any): Promise; - public getRequiredSteps( - currentState: any, - targetState: any, - extraState: any, - ignoreImages?: boolean, - ): Promise>>; - public localModeSwitchCompletion(): Promise; -} - -export { ApplicationManager }; diff --git a/src/application-manager.js b/src/application-manager.js deleted file mode 100644 index 8515b3b8..00000000 --- a/src/application-manager.js +++ /dev/null @@ -1,1506 +0,0 @@ -import * as Promise from 'bluebird'; -import * as _ from 'lodash'; -import * as EventEmitter from 'events'; -import * as express from 'express'; -import * as bodyParser from 'body-parser'; - -import * as constants from './lib/constants'; -import { log } from './lib/supervisor-console'; -import * as config from './config'; -import * as logger from './logger'; - -import { validateTargetContracts } from './lib/contracts'; -import { docker } from './lib/docker-utils'; -import { LocalModeManager } from './local-mode'; -import * as updateLock from './lib/update-lock'; -import { checkTruthy, checkInt, checkString } from './lib/validation'; -import { - ContractViolationError, - InternalInconsistencyError, -} from './lib/errors'; - -import * as dbFormat from './device-state/db-format'; - -import * as Images from './compose/images'; -import { Network } from './compose/network'; -import * as networkManager from './compose/network-manager'; -import * as volumeManager from './compose/volume-manager'; -import * as serviceManager from './compose/service-manager'; -import * as compositionSteps from './compose/composition-steps'; - -import { Proxyvisor } from './proxyvisor'; - -import { createV1Api } from './device-api/v1'; -import { createV2Api } from './device-api/v2'; -import { serviceAction } from './device-api/common'; - -import * as deviceState from './device-state'; -import * as apiBinder from './api-binder'; - -import * as db from './db'; - -// TODO: move this to an Image class? -const imageForService = (service) => ({ - name: service.imageName, - appId: service.appId, - serviceId: service.serviceId, - serviceName: service.serviceName, - imageId: service.imageId, - releaseId: service.releaseId, - dependent: 0, -}); - -const fetchAction = (service) => ({ - action: 'fetch', - image: imageForService(service), - serviceId: service.serviceId, - serviceName: service.serviceName, -}); - -// TODO: implement additional v2 endpoints -// Some v1 endpoins only work for single-container apps as they assume the app has a single service. -const createApplicationManagerRouter = function (applications) { - const router = express.Router(); - router.use(bodyParser.urlencoded({ extended: true, limit: '10mb' })); - router.use(bodyParser.json({ limit: '10mb' })); - - createV1Api(router, applications); - createV2Api(router, applications); - - router.use(applications.proxyvisor.router); - - return router; -}; - -export class ApplicationManager extends EventEmitter { - constructor() { - super(); - - this.serviceAction = serviceAction; - this.imageForService = imageForService; - this.fetchAction = fetchAction; - - this._strategySteps = { - 'download-then-kill'( - current, - target, - needsDownload, - dependenciesMetForKill, - ) { - if (needsDownload) { - return fetchAction(target); - } else if (dependenciesMetForKill()) { - // We only kill when dependencies are already met, so that we minimize downtime - return serviceAction('kill', target.serviceId, current, target); - } else { - return { action: 'noop' }; - } - }, - 'kill-then-download'(current, target) { - return serviceAction('kill', target.serviceId, current, target); - }, - 'delete-then-download'(current, target) { - return serviceAction('kill', target.serviceId, current, target); - }, - 'hand-over'( - current, - target, - needsDownload, - dependenciesMetForStart, - dependenciesMetForKill, - needsSpecialKill, - timeout, - ) { - if (needsDownload) { - return fetchAction(target); - } else if (needsSpecialKill && dependenciesMetForKill()) { - return serviceAction('kill', target.serviceId, current, target); - } else if (dependenciesMetForStart()) { - return serviceAction('handover', target.serviceId, current, target, { - timeout, - }); - } else { - return { action: 'noop' }; - } - }, - }; - - this.reportCurrentState = this.reportCurrentState.bind(this); - this.getStatus = this.getStatus.bind(this); - this.getDependentState = this.getDependentState.bind(this); - this.getCurrentForComparison = this.getCurrentForComparison.bind(this); - this.getCurrentApp = this.getCurrentApp.bind(this); - this.getTargetApp = this.getTargetApp.bind(this); - this.compareServicesForUpdate = this.compareServicesForUpdate.bind(this); - this.compareNetworksForUpdate = this.compareNetworksForUpdate.bind(this); - this.compareVolumesForUpdate = this.compareVolumesForUpdate.bind(this); - this._nextStepsForNetwork = this._nextStepsForNetwork.bind(this); - this._nextStepForService = this._nextStepForService.bind(this); - this._nextStepsForAppUpdate = this._nextStepsForAppUpdate.bind(this); - this.setTargetVolatileForService = this.setTargetVolatileForService.bind( - this, - ); - this.clearTargetVolatileForServices = this.clearTargetVolatileForServices.bind( - this, - ); - this.getTargetApps = this.getTargetApps.bind(this); - this.getDependentTargets = this.getDependentTargets.bind(this); - this._compareImages = this._compareImages.bind(this); - this._inferNextSteps = this._inferNextSteps.bind(this); - this.stopAll = this.stopAll.bind(this); - this._lockingIfNecessary = this._lockingIfNecessary.bind(this); - this.executeStepAction = this.executeStepAction.bind(this); - this.getExtraStateForComparison = this.getExtraStateForComparison.bind( - this, - ); - this.getRequiredSteps = this.getRequiredSteps.bind(this); - this.serviceNameFromId = this.serviceNameFromId.bind(this); - this.removeAllVolumesForApp = this.removeAllVolumesForApp.bind(this); - this.localModeSwitchCompletion = this.localModeSwitchCompletion.bind(this); - this.reportOptionalContainers = this.reportOptionalContainers.bind(this); - this.deviceState = deviceState; - this.apiBinder = apiBinder; - - this.proxyvisor = new Proxyvisor({ - applications: this, - }); - this.localModeManager = new LocalModeManager(); - this.timeSpentFetching = 0; - this.fetchesInProgress = 0; - this._targetVolatilePerImageId = {}; - this._containerStarted = {}; - - this.actionExecutors = compositionSteps.getExecutors({ - lockFn: this._lockingIfNecessary, - applications: this, - callbacks: { - containerStarted: (id) => { - this._containerStarted[id] = true; - }, - containerKilled: (id) => { - delete this._containerStarted[id]; - }, - fetchStart: () => { - this.fetchesInProgress += 1; - }, - fetchEnd: () => { - this.fetchesInProgress -= 1; - }, - fetchTime: (time) => { - this.timeSpentFetching += time; - }, - stateReport: (state) => this.reportCurrentState(state), - bestDeltaSource: this.bestDeltaSource, - }, - }); - this.validActions = _.keys(this.actionExecutors).concat( - this.proxyvisor.validActions, - ); - this.router = createApplicationManagerRouter(this); - Images.on('change', this.reportCurrentState); - serviceManager.on('change', this.reportCurrentState); - } - - reportCurrentState(data) { - return this.emit('change', data); - } - - async init() { - await Images.initialized; - await Images.cleanupDatabase(); - const cleanup = () => { - return docker.listContainers({ all: true }).then((containers) => { - return logger.clearOutOfDateDBLogs(_.map(containers, 'Id')); - }); - }; - // Rather than relying on removing out of date database entries when we're no - // longer using them, set a task that runs periodically to clear out the database - // This has the advantage that if for some reason a container is removed while the - // supervisor is down, we won't have zombie entries in the db - - // Once a day - setInterval(cleanup, 1000 * 60 * 60 * 24); - // But also run it in on startup - await cleanup(); - await this.localModeManager.init(); - await serviceManager.attachToRunning(); - await serviceManager.listenToEvents(); - } - - // Returns the status of applications and their services - getStatus() { - return Promise.join( - serviceManager.getStatus(), - Images.getStatus(), - config.get('currentCommit'), - function (services, images, currentCommit) { - const apps = {}; - const dependent = {}; - let releaseId = null; - const creationTimesAndReleases = {}; - // We iterate over the current running services and add them to the current state - // of the app they belong to. - for (const service of services) { - const { appId, imageId } = service; - if (apps[appId] == null) { - apps[appId] = {}; - } - creationTimesAndReleases[appId] = {}; - if (apps[appId].services == null) { - apps[appId].services = {}; - } - // We only send commit if all services have the same release, and it matches the target release - if (releaseId == null) { - ({ releaseId } = service); - } else if (releaseId !== service.releaseId) { - releaseId = false; - } - if (imageId == null) { - throw new InternalInconsistencyError( - `imageId not defined in ApplicationManager.getStatus: ${service}`, - ); - } - if (apps[appId].services[imageId] == null) { - apps[appId].services[imageId] = _.pick(service, [ - 'status', - 'releaseId', - ]); - creationTimesAndReleases[appId][imageId] = _.pick(service, [ - 'createdAt', - 'releaseId', - ]); - apps[appId].services[imageId].download_progress = null; - } else { - // There's two containers with the same imageId, so this has to be a handover - apps[appId].services[imageId].releaseId = _.minBy( - [creationTimesAndReleases[appId][imageId], service], - 'createdAt', - ).releaseId; - apps[appId].services[imageId].status = 'Handing over'; - } - } - - for (const image of images) { - const { appId } = image; - if (!image.dependent) { - if (apps[appId] == null) { - apps[appId] = {}; - } - if (apps[appId].services == null) { - apps[appId].services = {}; - } - if (apps[appId].services[image.imageId] == null) { - apps[appId].services[image.imageId] = _.pick(image, [ - 'status', - 'releaseId', - ]); - apps[appId].services[image.imageId].download_progress = - image.downloadProgress; - } - } else if (image.imageId != null) { - if (dependent[appId] == null) { - dependent[appId] = {}; - } - if (dependent[appId].images == null) { - dependent[appId].images = {}; - } - dependent[appId].images[image.imageId] = _.pick(image, ['status']); - dependent[appId].images[image.imageId].download_progress = - image.downloadProgress; - } else { - log.debug('Ignoring legacy dependent image', image); - } - } - - return { local: apps, dependent, commit: currentCommit }; - }, - ); - } - - getDependentState() { - return this.proxyvisor.getCurrentStates(); - } - - _buildApps(services, networks, volumes, currentCommit) { - /** @type {Dictionary} */ - const apps = {}; - - // We iterate over the current running services and add them to the current state - // of the app they belong to. - for (const service of services) { - const { appId } = service; - if (apps[appId] == null) { - apps[appId] = { appId, services: [], volumes: {}, networks: {} }; - } - apps[appId].services.push(service); - } - - for (const network of networks) { - const { appId } = network; - if (apps[appId] == null) { - apps[appId] = { appId, services: [], volumes: {}, networks: {} }; - } - apps[appId].networks[network.name] = network; - } - - for (const volume of volumes) { - const { appId } = volume; - if (apps[appId] == null) { - apps[appId] = { appId, services: [], volumes: {}, networks: {} }; - } - apps[appId].volumes[volume.name] = volume; - } - - // multi-app warning! - // This is just wrong on every level - _.each(apps, (app) => { - app.commit = currentCommit; - }); - - return apps; - } - - getCurrentForComparison() { - return Promise.join( - serviceManager.getAll(), - networkManager.getAll(), - volumeManager.getAll(), - config.get('currentCommit'), - this._buildApps, - ); - } - - getCurrentApp(appId) { - return Promise.join( - serviceManager.getAllByAppId(appId), - networkManager.getAllByAppId(appId), - volumeManager.getAllByAppId(appId), - config.get('currentCommit'), - this._buildApps, - ).get(appId); - } - - getTargetApp(appId) { - return dbFormat.getApp(appId); - } - - // Compares current and target services and returns a list of service pairs to be updated/removed/installed. - // The returned list is an array of objects where the "current" and "target" properties define the update pair, and either can be null - // (in the case of an install or removal). - compareServicesForUpdate(currentServices, targetServices, containerIds) { - const removePairs = []; - const installPairs = []; - const updatePairs = []; - const targetServiceIds = _.map(targetServices, 'serviceId'); - const currentServiceIds = _.uniq(_.map(currentServices, 'serviceId')); - - const toBeRemoved = _.difference(currentServiceIds, targetServiceIds); - for (const serviceId of toBeRemoved) { - const servicesToRemove = _.filter(currentServices, { serviceId }); - for (const service of servicesToRemove) { - removePairs.push({ - current: service, - target: null, - serviceId, - }); - } - } - - const toBeInstalled = _.difference(targetServiceIds, currentServiceIds); - for (const serviceId of toBeInstalled) { - const serviceToInstall = _.find(targetServices, { serviceId }); - if (serviceToInstall != null) { - installPairs.push({ - current: null, - target: serviceToInstall, - serviceId, - }); - } - } - - const toBeMaybeUpdated = _.intersection( - targetServiceIds, - currentServiceIds, - ); - const currentServicesPerId = {}; - const targetServicesPerId = _.keyBy(targetServices, 'serviceId'); - for (const serviceId of toBeMaybeUpdated) { - const currentServiceContainers = _.filter(currentServices, { serviceId }); - if (currentServiceContainers.length > 1) { - currentServicesPerId[serviceId] = _.maxBy( - currentServiceContainers, - 'createdAt', - ); - - // All but the latest container for this service are spurious and should be removed - for (const service of _.without( - currentServiceContainers, - currentServicesPerId[serviceId], - )) { - removePairs.push({ - current: service, - target: null, - serviceId, - }); - } - } else { - currentServicesPerId[serviceId] = currentServiceContainers[0]; - } - } - - // Returns true if a service matches its target except it should be running and it is not, but we've - // already started it before. In this case it means it just exited so we don't want to start it again. - const alreadyStarted = (serviceId) => { - return ( - currentServicesPerId[serviceId].isEqualExceptForRunningState( - targetServicesPerId[serviceId], - containerIds, - ) && - targetServicesPerId[serviceId].config.running && - this._containerStarted[currentServicesPerId[serviceId].containerId] - ); - }; - - const needUpdate = _.filter( - toBeMaybeUpdated, - (serviceId) => - !currentServicesPerId[serviceId].isEqual( - targetServicesPerId[serviceId], - containerIds, - ) && !alreadyStarted(serviceId), - ); - - for (const serviceId of needUpdate) { - updatePairs.push({ - current: currentServicesPerId[serviceId], - target: targetServicesPerId[serviceId], - serviceId, - }); - } - - return { removePairs, installPairs, updatePairs }; - } - - _compareNetworksOrVolumesForUpdate(_model, { current, target }) { - const outputPairs = []; - const currentNames = _.keys(current); - const targetNames = _.keys(target); - - const toBeRemoved = _.difference(currentNames, targetNames); - for (const name of toBeRemoved) { - outputPairs.push({ current: current[name], target: null }); - } - - const toBeInstalled = _.difference(targetNames, currentNames); - for (const name of toBeInstalled) { - outputPairs.push({ current: null, target: target[name] }); - } - - const toBeUpdated = _.filter( - _.intersection(targetNames, currentNames), - (name) => !current[name].isEqualConfig(target[name]), - ); - for (const name of toBeUpdated) { - outputPairs.push({ - current: current[name], - target: target[name], - }); - } - - return outputPairs; - } - - compareNetworksForUpdate({ current, target }) { - return this._compareNetworksOrVolumesForUpdate(networkManager, { - current, - target, - }); - } - - compareVolumesForUpdate({ current, target }) { - return this._compareNetworksOrVolumesForUpdate(volumeManager, { - current, - target, - }); - } - - // Checks if a service is using a network or volume that is about to be updated - _hasCurrentNetworksOrVolumes(service, networkPairs, volumePairs) { - if (service == null) { - return false; - } - const hasNetwork = _.some( - networkPairs, - (pair) => - `${service.appId}_${pair.current?.name}` === service.networkMode, - ); - if (hasNetwork) { - return true; - } - const hasVolume = _.some(service.volumes, function (volume) { - const name = _.split(volume, ':')[0]; - return _.some( - volumePairs, - (pair) => `${service.appId}_${pair.current?.name}` === name, - ); - }); - return hasVolume; - } - - // TODO: account for volumes-from, networks-from, links, etc - // TODO: support networks instead of only networkMode - _dependenciesMetForServiceStart( - target, - networkPairs, - volumePairs, - pendingPairs, - ) { - // for dependsOn, check no install or update pairs have that service - const dependencyUnmet = _.some(target.dependsOn, (dependency) => - _.some(pendingPairs, (pair) => pair.target?.serviceName === dependency), - ); - if (dependencyUnmet) { - return false; - } - // for networks and volumes, check no network pairs have that volume name - if ( - _.some( - networkPairs, - (pair) => `${target.appId}_${pair.target?.name}` === target.networkMode, - ) - ) { - return false; - } - const volumeUnmet = _.some(target.volumes, function (volumeDefinition) { - const [sourceName, destName] = volumeDefinition.split(':'); - if (destName == null) { - // If this is not a named volume, ignore it - return false; - } - return _.some( - volumePairs, - (pair) => `${target.appId}_${pair.target?.name}` === sourceName, - ); - }); - return !volumeUnmet; - } - - // Unless the update strategy requires an early kill (i.e. kill-then-download, delete-then-download), we only want - // to kill a service once the images for the services it depends on have been downloaded, so as to minimize - // downtime (but not block the killing too much, potentially causing a deadlock) - _dependenciesMetForServiceKill( - target, - targetApp, - availableImages, - localMode, - ) { - // Because we only check for an image being available, in local mode this will always - // be the case, so return true regardless. If this function ever checks for anything else, - // we'll need to change the logic here - if (localMode) { - return true; - } - if (target.dependsOn != null) { - for (const dependency of target.dependsOn) { - const dependencyService = _.find(targetApp.services, { - serviceName: dependency, - }); - if ( - !_.some( - availableImages, - (image) => - image.dockerImageId === dependencyService.image || - Images.isSameImage(image, { name: dependencyService.imageName }), - ) - ) { - return false; - } - } - } - return true; - } - - _nextStepsForNetworkOrVolume( - { current, target }, - currentApp, - changingPairs, - dependencyComparisonFn, - model, - ) { - // Check none of the currentApp.services use this network or volume - if (current != null) { - const dependencies = _.filter(currentApp.services, (service) => - dependencyComparisonFn(service, current), - ); - if (_.isEmpty(dependencies)) { - if (model === 'network') { - return [{ action: 'removeNetwork', current }]; - } - return []; - } else { - // If the current update doesn't require killing the services that use this network/volume, - // we have to kill them before removing the network/volume (e.g. when we're only updating the network config) - const steps = []; - for (const dependency of dependencies) { - if ( - dependency.status !== 'Stopping' && - !_.some(changingPairs, { serviceId: dependency.serviceId }) - ) { - steps.push(serviceAction('kill', dependency.serviceId, dependency)); - } - } - return steps; - } - } else if (target != null) { - const action = model === 'network' ? 'createNetwork' : 'createVolume'; - return [{ action, target }]; - } - } - - _nextStepsForNetwork({ current, target }, currentApp, changingPairs) { - const dependencyComparisonFn = (service, curr) => - service.config.networkMode === `${service.appId}_${curr?.name}`; - - return this._nextStepsForNetworkOrVolume( - { current, target }, - currentApp, - changingPairs, - dependencyComparisonFn, - 'network', - ); - } - - _nextStepsForVolume({ current, target }, currentApp, changingPairs) { - // Check none of the currentApp.services use this network or volume - const dependencyComparisonFn = (service, curr) => - _.some(service.config.volumes, function (volumeDefinition) { - const [sourceName, destName] = volumeDefinition.split(':'); - return ( - destName != null && sourceName === `${service.appId}_${curr?.name}` - ); - }); - return this._nextStepsForNetworkOrVolume( - { current, target }, - currentApp, - changingPairs, - dependencyComparisonFn, - 'volume', - ); - } - - // Infers steps that do not require creating a new container - _updateContainerStep(current, target) { - if ( - current.releaseId !== target.releaseId || - current.imageId !== target.imageId - ) { - return serviceAction('updateMetadata', target.serviceId, current, target); - } else if (target.config.running) { - return serviceAction('start', target.serviceId, current, target); - } else { - return serviceAction('stop', target.serviceId, current, target); - } - } - - _fetchOrStartStep(current, target, needsDownload, dependenciesMetForStart) { - if (needsDownload) { - return fetchAction(target); - } else if (dependenciesMetForStart()) { - return serviceAction('start', target.serviceId, current, target); - } else { - return null; - } - } - - _nextStepForService( - { current, target }, - updateContext, - localMode, - containerIds, - ) { - const { - targetApp, - networkPairs, - volumePairs, - installPairs, - updatePairs, - availableImages, - downloading, - } = updateContext; - if (current?.status === 'Stopping') { - // There is already a kill step in progress for this service, so we wait - return { action: 'noop' }; - } - - if (current?.status === 'Dead') { - // Dead containers have to be removed - return serviceAction('remove', current.serviceId, current); - } - - let needsDownload = false; - // Don't attempt to fetch any images in local mode, they should already be there - if (!localMode) { - needsDownload = !_.some( - availableImages, - (image) => - image.dockerImageId === target?.config.image || - Images.isSameImage(image, { name: target.imageName }), - ); - } - - // This service needs an image download but it's currently downloading, so we wait - if (needsDownload && downloading.includes(target?.imageId)) { - return { action: 'noop' }; - } - - const dependenciesMetForStart = () => { - return this._dependenciesMetForServiceStart( - target, - networkPairs, - volumePairs, - installPairs.concat(updatePairs), - ); - }; - const dependenciesMetForKill = () => { - return ( - !needsDownload && - this._dependenciesMetForServiceKill( - target, - targetApp, - availableImages, - localMode, - ) - ); - }; - - // If the service is using a network or volume that is being updated, we need to kill it - // even if its strategy is handover - const needsSpecialKill = this._hasCurrentNetworksOrVolumes( - current, - networkPairs, - volumePairs, - ); - - if (current?.isEqualConfig(target, containerIds)) { - // We're only stopping/starting it - return this._updateContainerStep(current, target); - } else if (current == null) { - // Either this is a new service, or the current one has already been killed - return this._fetchOrStartStep( - current, - target, - needsDownload, - dependenciesMetForStart, - ); - } else { - let strategy = checkString( - target.config.labels['io.balena.update.strategy'], - ); - const validStrategies = [ - 'download-then-kill', - 'kill-then-download', - 'delete-then-download', - 'hand-over', - ]; - if (!_.includes(validStrategies, strategy)) { - strategy = 'download-then-kill'; - } - const timeout = checkInt( - target.config.labels['io.balena.update.handover-timeout'], - ); - return this._strategySteps[strategy]( - current, - target, - needsDownload, - dependenciesMetForStart, - dependenciesMetForKill, - needsSpecialKill, - timeout, - ); - } - } - - _nextStepsForAppUpdate( - currentApp, - targetApp, - localMode, - containerIds, - availableImages, - downloading, - ) { - if (availableImages == null) { - availableImages = []; - } - if (downloading == null) { - downloading = []; - } - const emptyApp = { services: [], volumes: {}, networks: {} }; - if (targetApp == null) { - targetApp = emptyApp; - } else { - // Create the default network for the target app - if (targetApp.networks['default'] == null) { - targetApp.networks['default'] = Network.fromComposeObject( - 'default', - targetApp.appId, - {}, - ); - } - } - if (currentApp == null) { - currentApp = emptyApp; - } - if ( - currentApp.services?.length === 1 && - targetApp.services?.length === 1 && - targetApp.services[0].serviceName === - currentApp.services[0].serviceName && - checkTruthy( - currentApp.services[0].config.labels['io.balena.legacy-container'], - ) - ) { - // This is a legacy preloaded app or container, so we didn't have things like serviceId. - // We hack a few things to avoid an unnecessary restart of the preloaded app - // (but ensuring it gets updated if it actually changed) - targetApp.services[0].config.labels['io.balena.legacy-container'] = - currentApp.services[0].config.labels['io.balena.legacy-container']; - targetApp.services[0].config.labels['io.balena.service-id'] = - currentApp.services[0].config.labels['io.balena.service-id']; - targetApp.services[0].serviceId = currentApp.services[0].serviceId; - } - - const networkPairs = this.compareNetworksForUpdate({ - current: currentApp.networks, - target: targetApp.networks, - }); - const volumePairs = this.compareVolumesForUpdate({ - current: currentApp.volumes, - target: targetApp.volumes, - }); - const { - removePairs, - installPairs, - updatePairs, - } = this.compareServicesForUpdate( - currentApp.services, - targetApp.services, - containerIds, - ); - let steps = []; - // All removePairs get a 'kill' action - for (const pair of removePairs) { - if (pair.current.status !== 'Stopping') { - steps.push(serviceAction('kill', pair.current.serviceId, pair.current)); - } else { - steps.push({ action: 'noop' }); - } - } - - // next step for install pairs in download - start order, but start requires dependencies, networks and volumes met - // next step for update pairs in order by update strategy. start requires dependencies, networks and volumes met. - for (const pair of installPairs.concat(updatePairs)) { - const step = this._nextStepForService( - pair, - { - targetApp, - networkPairs, - volumePairs, - installPairs, - updatePairs, - availableImages, - downloading, - }, - localMode, - containerIds, - ); - if (step != null) { - steps.push(step); - } - } - // next step for network pairs - remove requires services killed, create kill if no pairs or steps affect that service - for (const pair of networkPairs) { - const pairSteps = this._nextStepsForNetwork( - pair, - currentApp, - removePairs.concat(updatePairs), - ); - steps = steps.concat(pairSteps); - } - // next step for volume pairs - remove requires services killed, create kill if no pairs or steps affect that service - for (const pair of volumePairs) { - const pairSteps = this._nextStepsForVolume( - pair, - currentApp, - removePairs.concat(updatePairs), - ); - steps = steps.concat(pairSteps); - } - - if ( - _.isEmpty(steps) && - targetApp.commit != null && - currentApp.commit !== targetApp.commit - ) { - steps.push({ - action: 'updateCommit', - target: targetApp.commit, - }); - } - - const appId = targetApp.appId ?? currentApp.appId; - return _.map(steps, (step) => _.assign({}, step, { appId })); - } - - async setTarget(apps, dependent, source, maybeTrx) { - const setInTransaction = async (filtered, trx) => { - await dbFormat.setApps(filtered, source, trx); - await trx('app') - .where({ source }) - .whereNotIn( - 'appId', - // Use apps here, rather than filteredApps, to - // avoid removing a release from the database - // without an application to replace it. - // Currently this will only happen if the release - // which would replace it fails a contract - // validation check - _.map(apps, (_v, appId) => checkInt(appId)), - ) - .del(); - - await this.proxyvisor.setTargetInTransaction(dependent, trx); - }; - - // We look at the container contracts here, as if we - // cannot run the release, we don't want it to be added - // to the database, overwriting the current release. This - // is because if we just reject the release, but leave it - // in the db, if for any reason the current state stops - // running, we won't restart it, leaving the device - // useless - The exception to this rule is when the only - // failing services are marked as optional, then we - // filter those out and add the target state to the database - /** @type { { [appName: string]: string[]; } } */ - const contractViolators = {}; - const fulfilledContracts = validateTargetContracts(apps); - const filteredApps = _.cloneDeep(apps); - _.each( - fulfilledContracts, - ( - { valid, unmetServices, fulfilledServices, unmetAndOptional }, - appId, - ) => { - if (!valid) { - contractViolators[apps[appId].name] = unmetServices; - return delete filteredApps[appId]; - } else { - // valid is true, but we could still be missing - // some optional containers, and need to filter - // these out of the target state - filteredApps[appId].services = _.pickBy( - filteredApps[appId].services, - ({ serviceName }) => fulfilledServices.includes(serviceName), - ); - if (unmetAndOptional.length !== 0) { - return this.reportOptionalContainers(unmetAndOptional); - } - } - }, - ); - let promise; - if (maybeTrx != null) { - promise = setInTransaction(filteredApps, maybeTrx); - } else { - promise = db.transaction(setInTransaction); - } - try { - await promise; - this._targetVolatilePerImageId = {}; - } finally { - if (!_.isEmpty(contractViolators)) { - throw new ContractViolationError(contractViolators); - } - } - } - - setTargetVolatileForService(imageId, target) { - if (this._targetVolatilePerImageId[imageId] == null) { - this._targetVolatilePerImageId[imageId] = {}; - } - return _.assign(this._targetVolatilePerImageId[imageId], target); - } - - clearTargetVolatileForServices(imageIds) { - return imageIds.map( - (imageId) => (this._targetVolatilePerImageId[imageId] = {}), - ); - } - - async getTargetApps() { - const apps = await dbFormat.getApps(); - - _.each(apps, (app) => { - if (!_.isEmpty(app.services)) { - app.services = _.mapValues(app.services, (svc) => { - if (this._targetVolatilePerImageId[svc.imageId] != null) { - return { - ...svc, - ...this._targetVolatilePerImageId[svc.imageId], - }; - } - return svc; - }); - } - }); - - return apps; - } - - getDependentTargets() { - return this.proxyvisor.getTarget(); - } - - bestDeltaSource(image, available) { - if (!image.dependent) { - for (const availableImage of available) { - if ( - availableImage.serviceName === image.serviceName && - availableImage.appId === image.appId - ) { - return availableImage.name; - } - } - } - for (const availableImage of available) { - if (availableImage.appId === image.appId) { - return availableImage.name; - } - } - return null; - } - - // returns: - // imagesToRemove: images that - // - are not used in the current state, and - // - are not going to be used in the target state, and - // - are not needed for delta source / pull caching or would be used for a service with delete-then-download as strategy - // imagesToSave: images that - // - are locally available (i.e. an image with the same digest exists) - // - are not saved to the DB with all their metadata (serviceId, serviceName, etc) - _compareImages(current, target, available, localMode) { - const allImagesForTargetApp = (app) => _.map(app.services, imageForService); - const allImagesForCurrentApp = (app) => - _.map(app.services, function (service) { - const img = - _.find(available, { - dockerImageId: service.config.image, - imageId: service.imageId, - }) ?? _.find(available, { dockerImageId: service.config.image }); - return _.omit(img, ['dockerImageId', 'id']); - }); - const allImageDockerIdsForTargetApp = (app) => - _(app.services) - .map((svc) => [svc.imageName, svc.config.image]) - .filter((img) => img[1] != null) - .value(); - - const availableWithoutIds = _.map(available, (image) => - _.omit(image, ['dockerImageId', 'id']), - ); - const currentImages = _.flatMap(current.local.apps, allImagesForCurrentApp); - const targetImages = _.flatMap(target.local.apps, allImagesForTargetApp); - const targetImageDockerIds = _.fromPairs( - _.flatMap(target.local.apps, allImageDockerIdsForTargetApp), - ); - - const availableAndUnused = _.filter( - availableWithoutIds, - (image) => - !_.some(currentImages.concat(targetImages), (imageInUse) => - _.isEqual(image, imageInUse), - ), - ); - - const imagesToDownload = _.filter( - targetImages, - (targetImage) => - !_.some(available, (availableImage) => - Images.isSameImage(availableImage, targetImage), - ), - ); - // Images that are available but we don't have them in the DB with the exact metadata: - let imagesToSave = []; - if (!localMode) { - imagesToSave = _.filter(targetImages, function (targetImage) { - const isActuallyAvailable = _.some(available, function ( - availableImage, - ) { - if (Images.isSameImage(availableImage, targetImage)) { - return true; - } - if ( - availableImage.dockerImageId === - targetImageDockerIds[targetImage.name] - ) { - return true; - } - return false; - }); - const isNotSaved = !_.some(availableWithoutIds, (img) => - _.isEqual(img, targetImage), - ); - return isActuallyAvailable && isNotSaved; - }); - } - - const deltaSources = _.map(imagesToDownload, (image) => { - return this.bestDeltaSource(image, available); - }); - const proxyvisorImages = this.proxyvisor.imagesInUse(current, target); - - const potentialDeleteThenDownload = _.filter( - current.local.apps.services, - (svc) => - svc.config.labels['io.balena.update.strategy'] === - 'delete-then-download' && svc.status === 'Stopped', - ); - - const imagesToRemove = _.filter( - availableAndUnused.concat(potentialDeleteThenDownload), - function (image) { - const notUsedForDelta = !_.includes(deltaSources, image.name); - const notUsedByProxyvisor = !_.some( - proxyvisorImages, - (proxyvisorImage) => - Images.isSameImage(image, { name: proxyvisorImage }), - ); - return notUsedForDelta && notUsedByProxyvisor; - }, - ); - return { imagesToSave, imagesToRemove }; - } - - _inferNextSteps( - cleanupNeeded, - availableImages, - downloading, - supervisorNetworkReady, - current, - target, - ignoreImages, - { localMode, delta }, - containerIds, - ) { - const volumePromises = []; - return Promise.try(() => { - if (localMode) { - ignoreImages = true; - } - const currentByAppId = current.local.apps ?? {}; - const targetByAppId = target.local.apps ?? {}; - - // Given we need to detect when a device is moved - // between applications, we do it this way. This code - // is going to change to an application-manager + - // application model, which means that we can just - // detect when an application is no longer referenced - // in the target state, and run the teardown that way. - // Until then, this essentially does the same thing. We - // check when every other part of the teardown for an - // application has been complete, and then append the - // volume removal steps. - // We also don't want to remove cloud volumes when - // switching to local mode - // multi-app warning: this will break - let appsForVolumeRemoval; - if (!localMode) { - const currentAppIds = _.keys(current.local.apps).map((n) => - checkInt(n), - ); - const targetAppIds = _.keys(target.local.apps).map((n) => checkInt(n)); - appsForVolumeRemoval = _.difference(currentAppIds, targetAppIds); - } - - let nextSteps = []; - if (!supervisorNetworkReady) { - // if the supervisor0 network isn't ready and there's any containers using it, we need - // to kill them - let containersUsingSupervisorNetwork = false; - for (const appId of _.keys(currentByAppId)) { - const { services } = currentByAppId[appId]; - for (const n in services) { - if ( - checkTruthy( - services[n].config.labels['io.balena.features.supervisor-api'], - ) - ) { - containersUsingSupervisorNetwork = true; - if (services[n].status !== 'Stopping') { - nextSteps.push( - serviceAction('kill', services[n].serviceId, services[n]), - ); - } else { - nextSteps.push({ action: 'noop' }); - } - } - } - } - if (!containersUsingSupervisorNetwork) { - nextSteps.push({ action: 'ensureSupervisorNetwork' }); - } - } else { - if (!ignoreImages && _.isEmpty(downloading)) { - if (cleanupNeeded) { - nextSteps.push({ action: 'cleanup' }); - } - const { imagesToRemove, imagesToSave } = this._compareImages( - current, - target, - availableImages, - localMode, - ); - for (const image of imagesToSave) { - nextSteps.push({ action: 'saveImage', image }); - } - if (_.isEmpty(imagesToSave)) { - for (const image of imagesToRemove) { - nextSteps.push({ action: 'removeImage', image }); - } - } - } - // If we have to remove any images, we do that before anything else - if (_.isEmpty(nextSteps)) { - const allAppIds = _.union( - _.keys(currentByAppId), - _.keys(targetByAppId), - ); - for (const appId of allAppIds) { - nextSteps = nextSteps.concat( - this._nextStepsForAppUpdate( - currentByAppId[appId], - targetByAppId[appId], - localMode, - containerIds[appId], - availableImages, - downloading, - ), - ); - if (_.includes(appsForVolumeRemoval, checkInt(appId))) { - // We check if everything else has been done for - // the old app to be removed. If it has, we then - // remove all of the volumes - if (_.every(nextSteps, { action: 'noop' })) { - volumePromises.push( - this.removeAllVolumesForApp(checkInt(appId)), - ); - } - } - } - } - } - const newDownloads = nextSteps.filter((s) => s.action === 'fetch').length; - - if (!ignoreImages && delta && newDownloads > 0) { - // Check that this is not the first pull for an - // application, as we want to download all images then - // Otherwise we want to limit the downloading of - // deltas to constants.maxDeltaDownloads - const appImages = _.groupBy(availableImages, 'appId'); - let downloadsToBlock = - downloading.length + newDownloads - constants.maxDeltaDownloads; - - nextSteps = nextSteps.filter(function (step) { - if (step.action === 'fetch' && downloadsToBlock > 0) { - const imagesForThisApp = appImages[step.image.appId]; - if (imagesForThisApp == null || imagesForThisApp.length === 0) { - // There isn't a valid image for the fetch - // step, so we keep it - return true; - } else { - downloadsToBlock -= 1; - return false; - } - } else { - return true; - } - }); - } - - if (!ignoreImages && _.isEmpty(nextSteps) && !_.isEmpty(downloading)) { - nextSteps.push({ action: 'noop' }); - } - return _.uniqWith(nextSteps, _.isEqual); - }).then((nextSteps) => - Promise.all(volumePromises).then(function (volSteps) { - nextSteps = nextSteps.concat(_.flatten(volSteps)); - return nextSteps; - }), - ); - } - - stopAll({ force = false, skipLock = false } = {}) { - return Promise.resolve(serviceManager.getAll()) - .map((service) => { - return this._lockingIfNecessary( - service.appId, - { force, skipLock }, - () => { - return serviceManager - .kill(service, { removeContainer: false, wait: true }) - .then(() => { - delete this._containerStarted[service.containerId]; - }); - }, - ); - }) - .return(); - } - - _lockingIfNecessary(appId, { force = false, skipLock = false } = {}, fn) { - if (skipLock) { - return Promise.try(fn); - } - return config - .get('lockOverride') - .then((lockOverride) => lockOverride || force) - .then((lockOverridden) => - updateLock.lock(appId, { force: lockOverridden }, fn), - ); - } - - executeStepAction(step, { force = false, skipLock = false } = {}) { - if (_.includes(this.proxyvisor.validActions, step.action)) { - return this.proxyvisor.executeStepAction(step); - } - if (!_.includes(this.validActions, step.action)) { - return Promise.reject(new Error(`Invalid action ${step.action}`)); - } - return this.actionExecutors[step.action]( - _.merge({}, step, { force, skipLock }), - ); - } - - getExtraStateForComparison(currentState, targetState) { - const containerIdsByAppId = {}; - _(currentState.local.apps) - .keys() - .concat(_.keys(targetState.local.apps)) - .uniq() - .each((id) => { - const intId = checkInt(id); - if (intId == null) { - throw new Error(`Invalid id: ${id}`); - } - containerIdsByAppId[intId] = serviceManager.getContainerIdMap(intId); - }); - - return config.get('localMode').then((localMode) => { - return Promise.props({ - cleanupNeeded: Images.isCleanupNeeded(), - availableImages: Images.getAvailable(), - downloading: Images.getDownloadingImageIds(), - supervisorNetworkReady: networkManager.supervisorNetworkReady(), - delta: config.get('delta'), - containerIds: Promise.props(containerIdsByAppId), - localMode, - }); - }); - } - - getRequiredSteps(currentState, targetState, extraState, ignoreImages) { - if (ignoreImages == null) { - ignoreImages = false; - } - let { - cleanupNeeded, - availableImages, - downloading, - supervisorNetworkReady, - delta, - localMode, - containerIds, - } = extraState; - const conf = { delta, localMode }; - if (conf.localMode) { - cleanupNeeded = false; - } - - return this._inferNextSteps( - cleanupNeeded, - availableImages, - downloading, - supervisorNetworkReady, - currentState, - targetState, - ignoreImages, - conf, - containerIds, - ).then((nextSteps) => { - if (ignoreImages && _.some(nextSteps, { action: 'fetch' })) { - throw new Error('Cannot fetch images while executing an API action'); - } - return this.proxyvisor - .getRequiredSteps( - availableImages, - downloading, - currentState, - targetState, - nextSteps, - ) - .then((proxyvisorSteps) => nextSteps.concat(proxyvisorSteps)); - }); - } - - serviceNameFromId(serviceId) { - return this.getTargetApps().then(function (apps) { - // Multi-app warning! - // We assume here that there will only be a single - // application - for (const appId of Object.keys(apps)) { - const app = apps[appId]; - const service = _.find( - app.services, - (svc) => svc.serviceId === serviceId, - ); - if (service?.serviceName == null) { - throw new InternalInconsistencyError( - `Could not find service name for id: ${serviceId}`, - ); - } - return service.serviceName; - } - throw new InternalInconsistencyError( - `Trying to get service name with no apps: ${serviceId}`, - ); - }); - } - - removeAllVolumesForApp(appId) { - return volumeManager.getAllByAppId(appId).then((volumes) => - volumes.map((v) => ({ - action: 'removeVolume', - current: v, - })), - ); - } - - localModeSwitchCompletion() { - return this.localModeManager.switchCompletion(); - } - - reportOptionalContainers(serviceNames) { - // Print logs to the console and dashboard, letting the - // user know that we're not going to run certain services - // because of their contract - const message = `Not running containers because of contract violations: ${serviceNames.join( - '. ', - )}`; - log.info(message); - return logger.logSystemMessage( - message, - {}, - 'optionalContainerViolation', - true, - ); - } -} diff --git a/src/compose/app.ts b/src/compose/app.ts new file mode 100644 index 00000000..9d219ca6 --- /dev/null +++ b/src/compose/app.ts @@ -0,0 +1,802 @@ +import * as _ from 'lodash'; +import { promises as fs } from 'fs'; +import * as path from 'path'; + +import Network from './network'; +import Volume from './volume'; +import Service from './service'; + +import * as imageManager from './images'; +import type { Image } from './images'; +import * as applicationManager from './application-manager'; +import { + CompositionStep, + generateStep, + CompositionStepAction, +} from './composition-steps'; +import * as targetStateCache from '../device-state/target-state-cache'; +import * as dockerUtils from '../lib/docker-utils'; +import constants = require('../lib/constants'); + +import { getStepsFromStrategy } from './update-strategies'; + +import { InternalInconsistencyError, NotFoundError } from '../lib/errors'; +import * as config from '../config'; +import { checkTruthy, checkString } from '../lib/validation'; +import { ServiceComposeConfig, DeviceMetadata } from './types/service'; +import { ImageInspectInfo } from 'dockerode'; +import { pathExistsOnHost } from '../lib/fs-utils'; + +export interface AppConstructOpts { + appId: number; + appName?: string; + commit?: string; + releaseId?: number; + source?: string; + + services: Service[]; + volumes: Dictionary; + networks: Dictionary; +} + +export interface UpdateState { + localMode: boolean; + availableImages: Image[]; + containerIds: Dictionary; + downloading: number[]; +} + +interface ChangingPair { + current?: T; + target?: T; +} + +export class App { + public appId: number; + // When setting up an application from current state, these values are not available + public appName?: string; + public commit?: string; + public releaseId?: number; + public source?: string; + + // Services are stored as an array, as at any one time we could have more than one + // service for a single service ID running (for example handover) + public services: Service[]; + public networks: Dictionary; + public volumes: Dictionary; + + public constructor(opts: AppConstructOpts, public isTargetState: boolean) { + this.appId = opts.appId; + this.appName = opts.appName; + this.commit = opts.commit; + this.releaseId = opts.releaseId; + this.source = opts.source; + this.services = opts.services; + this.volumes = opts.volumes; + this.networks = opts.networks; + + if (this.networks.default == null && isTargetState) { + // We always want a default network + this.networks.default = Network.fromComposeObject( + 'default', + opts.appId, + {}, + ); + } + } + + public nextStepsForAppUpdate( + state: UpdateState, + target: App, + ): CompositionStep[] { + // Check to see if we need to polyfill in some "new" data for legacy services + this.migrateLegacy(target); + + // Check for changes in the volumes. We don't remove any volumes until we remove an + // entire app + const volumeChanges = this.compareComponents( + this.volumes, + target.volumes, + false, + ); + const networkChanges = this.compareComponents( + this.networks, + target.networks, + true, + ); + + let steps: CompositionStep[] = []; + + // Any services which have died get a remove step + for (const service of this.services) { + if (service.status === 'Dead') { + steps.push(generateStep('remove', { current: service })); + } + } + + const { removePairs, installPairs, updatePairs } = this.compareServices( + this.services, + target.services, + state.containerIds, + ); + + for (const { current: svc } of removePairs) { + // All removes get a kill action if they're not already stopping + if (svc!.status !== 'Stopping') { + steps.push(generateStep('kill', { current: svc! })); + } else { + steps.push(generateStep('noop', {})); + } + } + + // For every service which needs to be updated, update via update strategy. + const servicePairs = updatePairs.concat(installPairs); + steps = steps.concat( + servicePairs + .map((pair) => + this.generateStepsForService(pair, { + ...state, + servicePairs: installPairs.concat(updatePairs), + targetApp: target, + networkPairs: networkChanges, + volumePairs: volumeChanges, + }), + ) + .filter((step) => step != null) as CompositionStep[], + ); + + // Generate volume steps + steps = steps.concat( + this.generateStepsForComponent(volumeChanges, servicePairs, (v, svc) => + // TODO: Volumes are stored without the appId prepended, but networks are stored + // with it prepended. Sort out this inequality + svc.config.volumes.includes(v.name), + ), + ); + // Generate network steps + steps = steps.concat( + this.generateStepsForComponent( + networkChanges, + servicePairs, + (n, svc) => `${this.appId}_${n.name}` in svc.config.networks, + ), + ); + + if ( + steps.length === 0 && + target.commit != null && + this.commit !== target.commit + ) { + // TODO: The next PR should change this to support multiapp commit values + steps.push(generateStep('updateCommit', { target: target.commit })); + } + + return steps; + } + + public async stepsToRemoveApp( + state: Omit, + ): Promise { + if (Object.keys(this.services).length > 0) { + return Object.values(this.services).map((service) => + generateStep('kill', { current: service }), + ); + } + if (Object.keys(this.networks).length > 0) { + return Object.values(this.networks).map((network) => + generateStep('removeNetwork', { current: network }), + ); + } + // Don't remove volumes in local mode + if (!state.localMode) { + if (Object.keys(this.volumes).length > 0) { + return Object.values(this.volumes).map((volume) => + generateStep('removeVolume', { current: volume }), + ); + } + } + + return []; + } + + private migrateLegacy(target: App) { + const currentServices = Object.values(this.services); + const targetServices = Object.values(target.services); + if ( + currentServices.length === 1 && + targetServices.length === 1 && + targetServices[0].serviceName === currentServices[0].serviceName && + checkTruthy( + currentServices[0].config.labels['io.balena.legacy-container'], + ) + ) { + // This is a legacy preloaded app or container, so we didn't have things like serviceId. + // We hack a few things to avoid an unnecessary restart of the preloaded app + // (but ensuring it gets updated if it actually changed) + targetServices[0].config.labels['io.balena.legacy-container'] = + currentServices[0].config.labels['io.balena.legacy-container']; + targetServices[0].config.labels['io.balena.service-id'] = + currentServices[0].config.labels['io.balena.service-id']; + targetServices[0].serviceId = currentServices[0].serviceId; + } + } + + private compareComponents( + current: Dictionary, + target: Dictionary, + // Should this function issue remove steps? (we don't want to for volumes) + generateRemoves: boolean, + ): Array> { + const currentNames = _.keys(current); + const targetNames = _.keys(target); + + const outputs: Array<{ current?: T; target?: T }> = []; + + if (generateRemoves) { + for (const name of _.difference(currentNames, targetNames)) { + outputs.push({ current: current[name] }); + } + } + for (const name of _.difference(targetNames, currentNames)) { + outputs.push({ target: target[name] }); + } + + const toBeUpdated = _.filter( + _.intersection(targetNames, currentNames), + (name) => !current[name].isEqualConfig(target[name]), + ); + for (const name of toBeUpdated) { + outputs.push({ current: current[name], target: target[name] }); + } + + return outputs; + } + + private compareServices( + current: Service[], + target: Service[], + containerIds: Dictionary, + ): { + installPairs: Array>; + removePairs: Array>; + updatePairs: Array>; + } { + const currentByServiceId = _.keyBy(current, 'serviceId'); + const targetByServiceId = _.keyBy(target, 'serviceId'); + + const currentServiceIds = Object.keys(currentByServiceId).map((i) => + parseInt(i, 10), + ); + const targetServiceIds = Object.keys(targetByServiceId).map((i) => + parseInt(i, 10), + ); + + const toBeRemoved = _(currentServiceIds) + .difference(targetServiceIds) + .map((id) => ({ current: currentByServiceId[id] })) + .value(); + + const toBeInstalled = _(targetServiceIds) + .difference(currentServiceIds) + .map((id) => ({ target: targetByServiceId[id] })) + .value(); + + const maybeUpdate = _.intersection(targetServiceIds, currentServiceIds); + + // Build up a list of services for a given service ID, always using the latest created + // service. Any older services will have kill steps emitted + for (const serviceId of maybeUpdate) { + const currentServiceContainers = _.filter(current, { serviceId }); + if (currentServiceContainers.length > 1) { + currentByServiceId[serviceId] = _.maxBy( + currentServiceContainers, + 'createdAt', + )!; + + // All but the latest container for the service are spurious and should + // be removed + const otherContainers = _.without( + currentServiceContainers, + currentByServiceId[serviceId], + ); + for (const service of otherContainers) { + toBeRemoved.push({ current: service }); + } + } else { + currentByServiceId[serviceId] = currentServiceContainers[0]; + } + } + + const alreadyStarted = (serviceId: number) => { + const equalExceptForRunning = currentByServiceId[ + serviceId + ].isEqualExceptForRunningState( + targetByServiceId[serviceId], + containerIds, + ); + if (!equalExceptForRunning) { + // We need to recreate the container, as the configuration has changed + return false; + } + + if (targetByServiceId[serviceId].config.running) { + // If the container is already running, and we don't need to change it + // due to the config, we know it's already been started + return true; + } + + // We recently ran a start step for this container, it just hasn't + // started running yet + return ( + applicationManager.containerStarted[ + currentByServiceId[serviceId].containerId! + ] != null + ); + }; + + const needUpdate = maybeUpdate.filter( + (serviceId) => + !( + currentByServiceId[serviceId].isEqual( + targetByServiceId[serviceId], + containerIds, + ) && alreadyStarted(serviceId) + ), + ); + const toBeUpdated = needUpdate.map((serviceId) => ({ + current: currentByServiceId[serviceId], + target: targetByServiceId[serviceId], + })); + + return { + installPairs: toBeInstalled, + removePairs: toBeRemoved, + updatePairs: toBeUpdated, + }; + } + + // We also accept a changingServices list, so we can avoid outputting multiple kill + // steps for a service + // FIXME: It would make the function simpler if we could just output the steps we want, + // and the nextStepsForAppUpdate function makes sure that we're not repeating steps. + // I'll leave it like this for now as this is how it was in application-manager.js, but + // it should be changed. + private generateStepsForComponent( + components: Array>, + changingServices: Array>, + dependencyFn: (component: T, service: Service) => boolean, + ): CompositionStep[] { + if (components.length === 0) { + return []; + } + + let steps: CompositionStep[] = []; + + const actions: { + create: CompositionStepAction; + remove: CompositionStepAction; + } = + (components[0].current ?? components[0].target) instanceof Volume + ? { create: 'createVolume', remove: 'removeVolume' } + : { create: 'createNetwork', remove: 'removeNetwork' }; + + for (const { current, target } of components) { + // If a current exists, we're either removing it or updating the configuration. In + // both cases, we must remove the component first, so we output those steps first. + // If we do remove the component, we first need to remove any services which depend + // on the component + if (current != null) { + // Find any services which are currently running which need to be killed when we + // recreate this component + const dependencies = _.filter(this.services, (s) => + dependencyFn(current, s), + ); + if (dependencies.length > 0) { + // We emit kill steps for these services, and wait to destroy the component in + // the next state application loop + // FIXME: We should add to the changingServices array, as we could emit several + // kill steps for a service + steps = steps.concat( + dependencies.reduce( + (acc, svc) => + acc.concat(this.generateKillStep(svc, changingServices)), + [] as CompositionStep[], + ), + ); + } else { + steps = steps.concat([generateStep(actions.remove, { current })]); + } + } else if (target != null) { + steps = steps.concat([generateStep(actions.create, { target })]); + } + } + + return steps; + } + + private generateStepsForService( + { current, target }: ChangingPair, + context: { + localMode: boolean; + availableImages: Image[]; + downloading: number[]; + targetApp: App; + containerIds: Dictionary; + networkPairs: Array>; + volumePairs: Array>; + servicePairs: Array>; + }, + ): Nullable { + if (current?.status === 'Stopping') { + // Theres a kill step happening already, emit a noop to ensure we stay alive while + // this happens + return generateStep('noop', {}); + } + if (current?.status === 'Dead') { + // A remove step will already have been generated, so we let the state + // application loop revisit this service, once the state has settled + return; + } + + let needsDownload = false; + // don't attempt to fetch images whilst in local mode, as they should be there already + if (!context.localMode) { + needsDownload = !_.some( + context.availableImages, + (image) => + image.dockerImageId === target?.config.image || + imageManager.isSameImage(image, { name: target?.imageName! }), + ); + } + + if (needsDownload && context.downloading.includes(target?.imageId!)) { + // The image needs to be downloaded, and it's currently downloading. We simply keep + // the application loop alive + return generateStep('noop', {}); + } + + if (target && current?.isEqualConfig(target, context.containerIds)) { + // we're only starting/stopping a service + return this.generateContainerStep(current, target); + } else if (current == null) { + // Either this is a new service, or the current one has already been killed + return this.generateFetchOrStartStep( + target!, + needsDownload, + context.networkPairs, + context.volumePairs, + context.servicePairs, + ); + } else { + if (!target) { + throw new InternalInconsistencyError( + 'An empty changing pair passed to generateStepsForService', + ); + } + const needsSpecialKill = this.serviceHasNetworkOrVolume( + current, + context.networkPairs, + context.volumePairs, + ); + + let strategy = + checkString(target.config.labels['io.balena.update.strategy']) || ''; + const validStrategies = [ + 'download-then-kill', + 'kill-then-download', + 'delete-then-download', + 'hand-over', + ]; + + if (!validStrategies.includes(strategy)) { + strategy = 'download-then-kill'; + } + + const dependenciesMetForStart = this.dependenciesMetForServiceStart( + target, + context.networkPairs, + context.volumePairs, + context.servicePairs, + ); + const dependenciesMetForKill = this.dependenciesMetForServiceKill( + target, + context.targetApp, + context.availableImages, + context.localMode, + ); + + return getStepsFromStrategy(strategy, { + current, + target, + needsDownload, + dependenciesMetForStart, + dependenciesMetForKill, + needsSpecialKill, + }); + } + } + + // We return an array from this function so the caller can just concatenate the arrays + // without worrying if the step is skipped or not + private generateKillStep( + service: Service, + changingServices: Array>, + ): CompositionStep[] { + if ( + service.status !== 'Stopping' && + !_.some( + changingServices, + ({ current }) => current?.serviceId !== service.serviceId, + ) + ) { + return [generateStep('kill', { current: service })]; + } else { + return []; + } + } + + private serviceHasNetworkOrVolume( + svc: Service, + networkPairs: Array>, + volumePairs: Array>, + ): boolean { + const serviceVolumes = svc.config.volumes; + for (const { current } of volumePairs) { + if (current && serviceVolumes.includes(`${this.appId}_${current.name}`)) { + return true; + } + } + + const serviceNetworks = svc.config.networks; + for (const { current } of networkPairs) { + if (current && `${this.appId}_${current.name}` in serviceNetworks) { + return true; + } + } + + return false; + } + + private generateContainerStep(current: Service, target: Service) { + // if the services release/image don't match, then rename the container... + if ( + current.releaseId !== target.releaseId || + current.imageId !== target.imageId + ) { + return generateStep('updateMetadata', { current, target }); + } else if (target.config.running !== current.config.running) { + if (target.config.running) { + return generateStep('start', { target }); + } else { + return generateStep('stop', { current }); + } + } + } + + private generateFetchOrStartStep( + target: Service, + needsDownload: boolean, + networkPairs: Array>, + volumePairs: Array>, + servicePairs: Array>, + ): CompositionStep | undefined { + if (needsDownload) { + // We know the service name exists as it always does for targets + return generateStep('fetch', { + image: imageManager.imageFromService(target), + serviceName: target.serviceName!, + }); + } else if ( + this.dependenciesMetForServiceStart( + target, + networkPairs, + volumePairs, + servicePairs, + ) + ) { + return generateStep('start', { target }); + } + } + + // TODO: account for volumes-from, networks-from, links, etc + // TODO: support networks instead of only network mode + private dependenciesMetForServiceStart( + target: Service, + networkPairs: Array>, + volumePairs: Array>, + servicePairs: Array>, + ): boolean { + // Firstly we check if a dependency is not already running (this is + // different to a dependency which is in the servicePairs below, as these + // are services which are changing). We could have a dependency which is + // starting up, but is not yet running. + const depInstallingButNotRunning = _.some(this.services, (svc) => { + if (target.dependsOn?.includes(svc.serviceName!)) { + if (!svc.config.running) { + return true; + } + } + }); + + if (depInstallingButNotRunning) { + return false; + } + + const depedencyUnmet = _.some(target.dependsOn, (dep) => + _.some(servicePairs, (pair) => pair.target?.serviceName === dep), + ); + + if (depedencyUnmet) { + return false; + } + + if ( + _.some( + networkPairs, + (pair) => + `${this.appId}_${pair.target?.name}` === target.config.networkMode, + ) + ) { + return false; + } + + if ( + _.some(target.config.volumes, (volumeDefinition) => { + const [sourceName, destName] = volumeDefinition.split(':'); + if (destName == null) { + // If this is not a named volume, ignore it + return false; + } + if (sourceName[0] === '/') { + // Absolute paths should also be ignored + return false; + } + + return _.some( + volumePairs, + (pair) => `${target.appId}_${pair.target?.name}` === sourceName, + ); + }) + ) { + return false; + } + + // everything is ready for the service to start... + return true; + } + + // Unless the update strategy requires an early kill (i.e kill-then-download, + // delete-then-download), we only want to kill a service once the images for the + // services it depends on have been downloaded, so as to minimize downtime (but not + // block the killing too much, potentially causing a daedlock) + private dependenciesMetForServiceKill( + target: Service, + targetApp: App, + availableImages: Image[], + localMode: boolean, + ) { + // because we only check for an image being available, in local mode this will always + // be the case, so return true regardless. If this function ever checks anything else, + // we'll need to change the logic here + if (localMode) { + return true; + } + + if (target.dependsOn != null) { + for (const dependency of target.dependsOn) { + const dependencyService = _.find(targetApp.services, { + serviceName: dependency, + }); + if ( + !_.some( + availableImages, + (image) => + image.dockerImageId === dependencyService?.imageId || + imageManager.isSameImage(image, { + name: dependencyService?.imageName!, + }), + ) + ) { + return false; + } + } + } + return true; + } + + public static async fromTargetState( + app: targetStateCache.DatabaseApp, + ): Promise { + const volumes = _.mapValues(JSON.parse(app.volumes) ?? {}, (conf, name) => { + if (conf == null) { + conf = {}; + } + if (conf.labels == null) { + conf.labels = {}; + } + return Volume.fromComposeObject(name, app.appId, conf); + }); + + const networks = _.mapValues( + JSON.parse(app.networks) ?? {}, + (conf, name) => { + return Network.fromComposeObject(name, app.appId, conf ?? {}); + }, + ); + + const [ + opts, + supervisorApiHost, + hostPathExists, + hostnameOnHost, + ] = await Promise.all([ + config.get('extendedEnvOptions'), + dockerUtils + .getNetworkGateway(constants.supervisorNetworkInterface) + .catch(() => '127.0.0.1'), + (async () => ({ + firmware: await pathExistsOnHost('/lib/firmware'), + modules: await pathExistsOnHost('/lib/modules'), + }))(), + (async () => + _.trim( + await fs.readFile( + path.join(constants.rootMountPoint, '/etc/hostname'), + 'utf8', + ), + ))(), + ]); + + const svcOpts = { + appName: app.name, + supervisorApiHost, + hostPathExists, + hostnameOnHost, + ...opts, + }; + + // In the db, the services are an array, but here we switch them to an + // object so that they are consistent + const services: Service[] = await Promise.all( + (JSON.parse(app.services) ?? []).map( + async (svc: ServiceComposeConfig) => { + // Try to fill the image id if the image is downloaded + let imageInfo: ImageInspectInfo | undefined; + try { + imageInfo = await imageManager.inspectByName(svc.image); + } catch (e) { + if (!NotFoundError(e)) { + throw e; + } + } + + const thisSvcOpts = { + ...svcOpts, + imageInfo, + serviceName: svc.serviceName, + }; + // FIXME: Typings for DeviceMetadata + return Service.fromComposeObject( + svc, + (thisSvcOpts as unknown) as DeviceMetadata, + ); + }, + ), + ); + return new App( + { + appId: app.appId, + commit: app.commit, + releaseId: app.releaseId, + appName: app.name, + source: app.source, + services, + volumes, + networks, + }, + true, + ); + } +} + +export default App; diff --git a/src/compose/application-manager.ts b/src/compose/application-manager.ts new file mode 100644 index 00000000..5d6fc02d --- /dev/null +++ b/src/compose/application-manager.ts @@ -0,0 +1,846 @@ +import * as bodyParser from 'body-parser'; +import * as express from 'express'; +import * as _ from 'lodash'; + +import * as config from '../config'; +import { transaction, Transaction } from '../db'; +import * as dbFormat from '../device-state/db-format'; +import { validateTargetContracts } from '../lib/contracts'; +import constants = require('../lib/constants'); +import { docker } from '../lib/docker-utils'; +import * as logger from '../logger'; +import log from '../lib/supervisor-console'; +import LocalModeManager from '../local-mode'; +import { + ContractViolationError, + InternalInconsistencyError, +} from '../lib/errors'; +import StrictEventEmitter from 'strict-event-emitter-types'; + +import App from './app'; +import * as volumeManager from './volume-manager'; +import * as networkManager from './network-manager'; +import * as serviceManager from './service-manager'; +import * as imageManager from './images'; +import type { Image } from './images'; +import { getExecutors, CompositionStepT } from './composition-steps'; + +import Service from './service'; + +import { createV1Api } from '../device-api/v1'; +import { createV2Api } from '../device-api/v2'; +import { CompositionStep, generateStep } from './composition-steps'; +import { + InstancedAppState, + TargetApplications, + DeviceStatus, + DeviceReportFields, +} from '../types/state'; +import { checkTruthy, checkInt } from '../lib/validation'; +import { Proxyvisor } from '../proxyvisor'; +import * as updateLock from '../lib/update-lock'; +import { EventEmitter } from 'events'; + +type ApplicationManagerEventEmitter = StrictEventEmitter< + EventEmitter, + { change: DeviceReportFields } +>; +const events: ApplicationManagerEventEmitter = new EventEmitter(); +export const on: typeof events['on'] = events.on.bind(events); +export const once: typeof events['once'] = events.once.bind(events); +export const removeListener: typeof events['removeListener'] = events.removeListener.bind( + events, +); +export const removeAllListeners: typeof events['removeAllListeners'] = events.removeAllListeners.bind( + events, +); + +const proxyvisor = new Proxyvisor(); +const localModeManager = new LocalModeManager(); + +export const router = (() => { + const $router = express.Router(); + $router.use(bodyParser.urlencoded({ extended: true, limit: '10mb' })); + $router.use(bodyParser.json({ limit: '10mb' })); + + createV1Api($router); + createV2Api($router); + + $router.use(proxyvisor.router); + + return $router; +})(); + +// We keep track of the containers we've started, to avoid triggering successive start +// requests for a container +export let containerStarted: { [containerId: string]: boolean } = {}; +export let fetchesInProgress = 0; +export let timeSpentFetching = 0; + +export function resetTimeSpentFetching(value: number = 0) { + timeSpentFetching = value; +} + +const actionExecutors = getExecutors({ + lockFn: lockingIfNecessary, + callbacks: { + containerStarted: (id: string) => { + containerStarted[id] = true; + }, + containerKilled: (id: string) => { + delete containerStarted[id]; + }, + fetchStart: () => { + fetchesInProgress += 1; + }, + fetchEnd: () => { + fetchesInProgress -= 1; + }, + fetchTime: (time) => { + timeSpentFetching += time; + }, + stateReport: (state) => { + reportCurrentState(state); + }, + bestDeltaSource, + }, +}); + +export const validActions = Object.keys(actionExecutors); + +// Volatile state for a single container. This is used for temporarily setting a +// different state for a container, such as running: false +let targetVolatilePerImageId: { + [imageId: number]: Partial; +} = {}; + +export const initialized = (async () => { + await config.initialized; + + await imageManager.initialized; + await imageManager.cleanupDatabase(); + const cleanup = async () => { + const containers = await docker.listContainers({ all: true }); + await logger.clearOutOfDateDBLogs(_.map(containers, 'Id')); + }; + + // Rather than relying on removing out of date database entries when we're no + // longer using them, set a task that runs periodically to clear out the database + // This has the advantage that if for some reason a container is removed while the + // supervisor is down, we won't have zombie entries in the db + + // Once a day + setInterval(cleanup, 1000 * 60 * 60 * 24); + // But also run it in on startup + await cleanup(); + + await localModeManager.init(); + await serviceManager.attachToRunning(); + serviceManager.listenToEvents(); + + imageManager.on('change', reportCurrentState); + serviceManager.on('change', reportCurrentState); +})(); + +export function getDependentState() { + return proxyvisor.getCurrentStates(); +} + +function reportCurrentState(data?: Partial) { + events.emit('change', data ?? {}); +} + +export async function lockingIfNecessary( + appId: number, + { force = false, skipLock = false } = {}, + fn: () => Resolvable, +) { + if (skipLock) { + return fn(); + } + const lockOverride = (await config.get('lockOverride')) || force; + return updateLock.lock( + appId, + { force: lockOverride }, + fn as () => PromiseLike, + ); +} + +export async function getRequiredSteps( + targetApps: InstancedAppState, + ignoreImages: boolean = false, +): Promise { + // get some required data + const [ + { localMode, delta }, + downloading, + cleanupNeeded, + availableImages, + currentApps, + ] = await Promise.all([ + config.getMany(['localMode', 'delta']), + imageManager.getDownloadingImageIds(), + imageManager.isCleanupNeeded(), + imageManager.getAvailable(), + getCurrentApps(), + ]); + const containerIdsByAppId = await getAppContainerIds(currentApps); + + if (localMode) { + ignoreImages = localMode; + } + + const currentAppIds = Object.keys(currentApps).map((i) => parseInt(i, 10)); + const targetAppIds = Object.keys(targetApps).map((i) => parseInt(i, 10)); + + let steps: CompositionStep[] = []; + + // First check if we need to create the supervisor network + if (!(await networkManager.supervisorNetworkReady())) { + // If we do need to create it, we first need to kill any services using the api + const killSteps = steps.concat(killServicesUsingApi(currentApps)); + if (killSteps.length > 0) { + steps = steps.concat(killSteps); + } else { + steps.push({ action: 'ensureSupervisorNetwork' }); + } + } else { + if (!localMode && downloading.length === 0) { + if (cleanupNeeded) { + steps.push({ action: 'cleanup' }); + } + + // Detect any images which must be saved/removed + steps = steps.concat( + saveAndRemoveImages( + currentApps, + targetApps, + availableImages, + localMode, + ), + ); + } + + // We want to remove images before moving on to anything else + if (steps.length === 0) { + const targetAndCurrent = _.intersection(currentAppIds, targetAppIds); + const onlyTarget = _.difference(targetAppIds, currentAppIds); + const onlyCurrent = _.difference(currentAppIds, targetAppIds); + + // For apps that exist in both current and target state, calculate what we need to + // do to move to the target state + for (const id of targetAndCurrent) { + steps = steps.concat( + await currentApps[id].nextStepsForAppUpdate( + { + localMode, + availableImages, + containerIds: containerIdsByAppId[id], + downloading, + }, + targetApps[id], + ), + ); + } + + // For apps in the current state but not target, we call their "destructor" + for (const id of onlyCurrent) { + steps = steps.concat( + await currentApps[id].stepsToRemoveApp({ + localMode, + downloading, + containerIds: containerIdsByAppId[id], + }), + ); + } + + // For apps in the target state but not the current state, we generate steps to + // create the app by mocking an existing app which contains nothing + for (const id of onlyTarget) { + const { appId } = targetApps[id]; + const emptyCurrent = new App( + { + appId, + services: [], + volumes: {}, + networks: {}, + }, + false, + ); + steps = steps.concat( + emptyCurrent.nextStepsForAppUpdate( + { + localMode, + availableImages, + containerIds: containerIdsByAppId[id] ?? {}, + downloading, + }, + targetApps[id], + ), + ); + } + } + } + + const newDownloads = steps.filter((s) => s.action === 'fetch').length; + if (!ignoreImages && delta && newDownloads > 0) { + // Check that this is not the first pull for an + // application, as we want to download all images then + // Otherwise we want to limit the downloading of + // deltas to constants.maxDeltaDownloads + const appImages = _.groupBy(availableImages, 'appId'); + let downloadsToBlock = + downloading.length + newDownloads - constants.maxDeltaDownloads; + + steps = steps.filter((step) => { + if (step.action === 'fetch' && downloadsToBlock > 0) { + const imagesForThisApp = + appImages[(step as CompositionStepT<'fetch'>).image.appId]; + if (imagesForThisApp == null || imagesForThisApp.length === 0) { + // There isn't a valid image for the fetch + // step, so we keep it + return true; + } else { + downloadsToBlock -= 1; + return false; + } + } else { + return true; + } + }); + } + + if (!ignoreImages && steps.length === 0 && downloading.length > 0) { + // We want to keep the state application alive + steps.push(generateStep('noop', {})); + } + + steps = steps.concat( + await proxyvisor.getRequiredSteps( + availableImages, + downloading, + currentApps, + targetApps, + steps, + ), + ); + return steps; +} + +export async function stopAll({ force = false, skipLock = false } = {}) { + const services = await serviceManager.getAll(); + await Promise.all( + services.map(async (s) => { + return lockingIfNecessary(s.appId, { force, skipLock }, async () => { + await serviceManager.kill(s, { removeContainer: false, wait: true }); + if (s.containerId) { + delete containerStarted[s.containerId]; + } + }); + }), + ); +} + +export async function getCurrentAppsForReport(): Promise< + NonNullable['apps'] +> { + const apps = await getCurrentApps(); + + const appsToReport: NonNullable['apps'] = {}; + for (const appId of Object.getOwnPropertyNames(apps)) { + appsToReport[appId] = { + services: {}, + }; + } + + return appsToReport; +} + +export async function getCurrentApps(): Promise { + const volumes = _.groupBy(await volumeManager.getAll(), 'appId'); + const networks = _.groupBy(await networkManager.getAll(), 'appId'); + const services = _.groupBy(await serviceManager.getAll(), 'appId'); + + const allAppIds = _.union( + Object.keys(volumes), + Object.keys(networks), + Object.keys(services), + ).map((i) => parseInt(i, 10)); + + // TODO: This will break with multiple apps + const commit = (await config.get('currentCommit')) ?? undefined; + + return _.keyBy( + allAppIds.map((appId) => { + return new App( + { + appId, + services: services[appId] ?? [], + networks: _.keyBy(networks[appId], 'name'), + volumes: _.keyBy(volumes[appId], 'name'), + commit, + }, + false, + ); + }), + 'appId', + ); +} + +function killServicesUsingApi(current: InstancedAppState): CompositionStep[] { + const steps: CompositionStep[] = []; + _.each(current, (app) => { + _.each(app.services, (service) => { + if ( + checkTruthy( + service.config.labels['io.balena.features.supervisor-api'], + ) && + service.status !== 'Stopping' + ) { + steps.push(generateStep('kill', { current: service })); + } else { + // We want to output a noop while waiting for a service to stop, as we don't want + // the state application loop to stop while this is ongoing + steps.push(generateStep('noop', {})); + } + }); + }); + return steps; +} + +export async function executeStep( + step: CompositionStep, + { force = false, skipLock = false } = {}, +): Promise { + if (proxyvisor.validActions.includes(step.action)) { + return proxyvisor.executeStepAction(step); + } + + if (!validActions.includes(step.action)) { + return Promise.reject( + new InternalInconsistencyError( + `Invalid composition step action: ${step.action}`, + ), + ); + } + + // TODO: Find out why this needs to be cast, the typings should hold true + await actionExecutors[step.action]({ + ...step, + force, + skipLock, + } as any); +} + +// FIXME: This shouldn't be in this module +export async function setTarget( + apps: TargetApplications, + dependent: any, + source: string, + maybeTrx?: Transaction, +) { + const setInTransaction = async ( + $filteredApps: TargetApplications, + trx: Transaction, + ) => { + await dbFormat.setApps($filteredApps, source, trx); + await trx('app') + .where({ source }) + .whereNotIn( + 'appId', + // Use apps here, rather than filteredApps, to + // avoid removing a release from the database + // without an application to replace it. + // Currently this will only happen if the release + // which would replace it fails a contract + // validation check + _.map(apps, (_v, appId) => checkInt(appId)), + ) + .del(); + await proxyvisor.setTargetInTransaction(dependent, trx); + }; + + // We look at the container contracts here, as if we + // cannot run the release, we don't want it to be added + // to the database, overwriting the current release. This + // is because if we just reject the release, but leave it + // in the db, if for any reason the current state stops + // running, we won't restart it, leaving the device + // useless - The exception to this rule is when the only + // failing services are marked as optional, then we + // filter those out and add the target state to the database + const contractViolators: { [appName: string]: string[] } = {}; + const fulfilledContracts = validateTargetContracts(apps); + const filteredApps = _.cloneDeep(apps); + _.each( + fulfilledContracts, + ({ valid, unmetServices, fulfilledServices, unmetAndOptional }, appId) => { + if (!valid) { + contractViolators[apps[appId].name] = unmetServices; + return delete filteredApps[appId]; + } else { + // valid is true, but we could still be missing + // some optional containers, and need to filter + // these out of the target state + filteredApps[appId].services = _.pickBy( + filteredApps[appId].services, + ({ serviceName }) => fulfilledServices.includes(serviceName), + ); + if (unmetAndOptional.length !== 0) { + return reportOptionalContainers(unmetAndOptional); + } + } + }, + ); + let promise; + if (maybeTrx != null) { + promise = setInTransaction(filteredApps, maybeTrx); + } else { + promise = transaction((trx) => setInTransaction(filteredApps, trx)); + } + await promise; + targetVolatilePerImageId = {}; + if (!_.isEmpty(contractViolators)) { + throw new ContractViolationError(contractViolators); + } +} + +export async function getTargetApps(): Promise { + const apps = await dbFormat.getTargetJson(); + + // Whilst it may make sense here to return the target state generated from the + // internal instanced representation that we have, we make irreversable + // changes to the input target state to avoid having undefined entries into + // the instances throughout the supervisor. The target state is derived from + // the database entries anyway, so these two things should never be different + // (except for the volatile state) + + _.each(apps, (app) => { + if (!_.isEmpty(app.services)) { + app.services = _.mapValues(app.services, (svc) => { + if (svc.imageId && targetVolatilePerImageId[svc.imageId] != null) { + return { ...svc, ...targetVolatilePerImageId }; + } + return svc; + }); + } + }); + + return apps; +} + +export function setTargetVolatileForService( + imageId: number, + target: Partial, +) { + if (targetVolatilePerImageId[imageId] == null) { + targetVolatilePerImageId = {}; + } + targetVolatilePerImageId[imageId] = target; +} + +export function clearTargetVolatileForServices(imageIds: number[]) { + for (const imageId of imageIds) { + targetVolatilePerImageId[imageId] = {}; + } +} + +export function getDependentTargets() { + return proxyvisor.getTarget(); +} + +export async function serviceNameFromId(serviceId: number) { + // We get the target here as it shouldn't matter, and getting the target is cheaper + const targets = await getTargetApps(); + for (const appId of Object.keys(targets)) { + const app = targets[parseInt(appId, 10)]; + const service = _.find(app.services, { serviceId }); + if (service?.serviceName === null) { + throw new InternalInconsistencyError( + `Could not find a service name for id: ${serviceId}`, + ); + } + return service!.serviceName; + } + throw new InternalInconsistencyError( + `Could not find a service for id: ${serviceId}`, + ); +} + +export function localModeSwitchCompletion() { + return localModeManager.switchCompletion(); +} + +export function bestDeltaSource( + image: Image, + available: Image[], +): string | null { + if (!image.dependent) { + for (const availableImage of available) { + if ( + availableImage.serviceName === image.serviceName && + availableImage.appId === image.appId + ) { + return availableImage.name; + } + } + } + for (const availableImage of available) { + if (availableImage.appId === image.appId) { + return availableImage.name; + } + } + return null; +} + +// We need to consider images for all apps, and not app-by-app, so we handle this here, +// rather than in the App class +// TODO: This function was taken directly from the old application manager, because it's +// complex enough that it's not really worth changing this along with the rest of the +// application-manager class. We should make this function much less opaque. +// Ideally we'd have images saved against specific apps, and those apps handle the +// lifecycle of said image +function saveAndRemoveImages( + current: InstancedAppState, + target: InstancedAppState, + availableImages: imageManager.Image[], + localMode: boolean, +): CompositionStep[] { + const imageForService = (service: Service): imageManager.Image => ({ + name: service.imageName!, + appId: service.appId, + serviceId: service.serviceId!, + serviceName: service.serviceName!, + imageId: service.imageId!, + releaseId: service.releaseId!, + dependent: 0, + }); + type ImageWithoutID = Omit; + + // imagesToRemove: images that + // - are not used in the current state, and + // - are not going to be used in the target state, and + // - are not needed for delta source / pull caching or would be used for a service with delete-then-download as strategy + // imagesToSave: images that + // - are locally available (i.e. an image with the same digest exists) + // - are not saved to the DB with all their metadata (serviceId, serviceName, etc) + + const allImageDockerIdsForTargetApp = (app: App) => + _(app.services) + .map((svc) => [svc.imageName, svc.config.image]) + .filter((img) => img[1] != null) + .value(); + + const availableWithoutIds: ImageWithoutID[] = _.map( + availableImages, + (image) => _.omit(image, ['dockerImageId', 'id']), + ); + + const currentImages = _.flatMap(current, (app) => + _.map( + app.services, + (svc) => + _.find(availableImages, { + dockerImageId: svc.config.image, + imageId: svc.imageId, + }) ?? _.find(availableImages, { dockerImageId: svc.config.image }), + ), + ) as imageManager.Image[]; + const targetImages = _.flatMap(target, (app) => + _.map(app.services, imageForService), + ); + + const availableAndUnused = _.filter( + availableImages, + (image) => + !_.some(currentImages.concat(targetImages), (imageInUse) => { + return ( + imageManager.isSameImage(image, imageInUse) || + image.id === imageInUse?.id || + image.dockerImageId === imageInUse?.dockerImageId + ); + }), + ); + + const imagesToDownload = _.filter( + targetImages, + (targetImage) => + !_.some(availableImages, (available) => + imageManager.isSameImage(available, targetImage), + ), + ); + + const targetImageDockerIds = _.fromPairs( + _.flatMap(target, allImageDockerIdsForTargetApp), + ); + + // Images that are available but we don't have them in the DB with the exact metadata: + let imagesToSave: imageManager.Image[] = []; + if (!localMode) { + imagesToSave = _.filter(targetImages, (targetImage) => { + const isActuallyAvailable = _.some(availableImages, (availableImage) => { + if (imageManager.isSameImage(availableImage, targetImage)) { + return true; + } + if ( + availableImage.dockerImageId === + targetImageDockerIds[targetImage.name] + ) { + return true; + } + return false; + }); + const isNotSaved = !_.some(availableWithoutIds, (img) => + _.isEqual(img, targetImage), + ); + return isActuallyAvailable && isNotSaved; + }); + } + + const deltaSources = _.map(imagesToDownload, (image) => { + return bestDeltaSource(image, availableImages); + }); + const proxyvisorImages = proxyvisor.imagesInUse(current, target); + + const potentialDeleteThenDownload = _(current) + .flatMap((app) => _.values(app.services)) + .filter( + (svc) => + svc.config.labels['io.balena.update.strategy'] === + 'delete-then-download' && svc.status === 'Stopped', + ) + .value(); + + const imagesToRemove = _.filter( + availableAndUnused.concat(potentialDeleteThenDownload.map(imageForService)), + (image) => { + const notUsedForDelta = !_.includes(deltaSources, image.name); + const notUsedByProxyvisor = !_.some(proxyvisorImages, (proxyvisorImage) => + imageManager.isSameImage(image, { name: proxyvisorImage }), + ); + return notUsedForDelta && notUsedByProxyvisor; + }, + ); + + return imagesToSave + .map((image) => ({ action: 'saveImage', image } as CompositionStep)) + .concat(imagesToRemove.map((image) => ({ action: 'removeImage', image }))); +} + +async function getAppContainerIds(currentApps: InstancedAppState) { + const containerIds: { [appId: number]: Dictionary } = {}; + await Promise.all( + _.map(currentApps, async (_app, appId) => { + const intAppId = parseInt(appId, 10); + containerIds[intAppId] = await serviceManager.getContainerIdMap(intAppId); + }), + ); + + return containerIds; +} + +function reportOptionalContainers(serviceNames: string[]) { + // Print logs to the console and dashboard, letting the + // user know that we're not going to run certain services + // because of their contract + const message = `Not running containers because of contract violations: ${serviceNames.join( + '. ', + )}`; + log.info(message); + return logger.logSystemMessage( + message, + {}, + 'optionalContainerViolation', + true, + ); +} + +// FIXME: This would be better to implement using the App class, and have each one +// generate its status. For now we use the original from application-manager.coffee. +export async function getStatus() { + const [services, images, currentCommit] = await Promise.all([ + serviceManager.getStatus(), + imageManager.getStatus(), + config.get('currentCommit'), + ]); + + const apps: Dictionary = {}; + const dependent: Dictionary = {}; + let releaseId: number | boolean | null | undefined = null; // ???? + const creationTimesAndReleases: Dictionary = {}; + // We iterate over the current running services and add them to the current state + // of the app they belong to. + for (const service of services) { + const { appId, imageId } = service; + if (!appId) { + continue; + } + if (apps[appId] == null) { + apps[appId] = {}; + } + creationTimesAndReleases[appId] = {}; + if (apps[appId].services == null) { + apps[appId].services = {}; + } + // We only send commit if all services have the same release, and it matches the target release + if (releaseId == null) { + ({ releaseId } = service); + } else if (releaseId !== service.releaseId) { + releaseId = false; + } + if (imageId == null) { + throw new InternalInconsistencyError( + `imageId not defined in ApplicationManager.getStatus: ${service}`, + ); + } + if (apps[appId].services[imageId] == null) { + apps[appId].services[imageId] = _.pick(service, ['status', 'releaseId']); + creationTimesAndReleases[appId][imageId] = _.pick(service, [ + 'createdAt', + 'releaseId', + ]); + apps[appId].services[imageId].download_progress = null; + } else { + // There's two containers with the same imageId, so this has to be a handover + apps[appId].services[imageId].releaseId = _.minBy( + [creationTimesAndReleases[appId][imageId], service], + 'createdAt', + ).releaseId; + apps[appId].services[imageId].status = 'Handing over'; + } + } + + for (const image of images) { + const { appId } = image; + if (!image.dependent) { + if (apps[appId] == null) { + apps[appId] = {}; + } + if (apps[appId].services == null) { + apps[appId].services = {}; + } + if (apps[appId].services[image.imageId] == null) { + apps[appId].services[image.imageId] = _.pick(image, [ + 'status', + 'releaseId', + ]); + apps[appId].services[image.imageId].download_progress = + image.downloadProgress; + } + } else if (image.imageId != null) { + if (dependent[appId] == null) { + dependent[appId] = {}; + } + if (dependent[appId].images == null) { + dependent[appId].images = {}; + } + dependent[appId].images[image.imageId] = _.pick(image, ['status']); + dependent[appId].images[image.imageId].download_progress = + image.downloadProgress; + } else { + log.debug('Ignoring legacy dependent image', image); + } + } + + return { local: apps, dependent, commit: currentCommit }; +} diff --git a/src/compose/composition-steps.ts b/src/compose/composition-steps.ts index d20d86f8..5e031088 100644 --- a/src/compose/composition-steps.ts +++ b/src/compose/composition-steps.ts @@ -2,7 +2,7 @@ import * as _ from 'lodash'; import * as config from '../config'; -import { ApplicationManager } from '../application-manager'; +import * as applicationManager from './application-manager'; import type { Image } from './images'; import * as images from './images'; import Network from './network'; @@ -13,6 +13,7 @@ import Volume from './volume'; import { checkTruthy } from '../lib/validation'; import * as networkManager from './network-manager'; import * as volumeManager from './volume-manager'; +import { DeviceReportFields } from '../types/state'; interface BaseCompositionStepArgs { force?: boolean; @@ -36,7 +37,6 @@ interface CompositionStepArgs { options?: { skipLock?: boolean; wait?: boolean; - removeImage?: boolean; }; } & BaseCompositionStepArgs; remove: { @@ -44,7 +44,7 @@ interface CompositionStepArgs { } & BaseCompositionStepArgs; updateMetadata: { current: Service; - target: { imageId: number; releaseId: number }; + target: Service; options?: { skipLock?: boolean; }; @@ -68,6 +68,7 @@ interface CompositionStepArgs { target: Service; options?: { skipLock?: boolean; + timeout?: number; }; } & BaseCompositionStepArgs; fetch: { @@ -94,17 +95,19 @@ interface CompositionStepArgs { current: Volume; }; ensureSupervisorNetwork: {}; + noop: {}; } export type CompositionStepAction = keyof CompositionStepArgs; -export type CompositionStep = { +export type CompositionStepT = { action: T; } & CompositionStepArgs[T]; +export type CompositionStep = CompositionStepT; export function generateStep( action: T, args: CompositionStepArgs[T], -): CompositionStep { +): CompositionStep { return { action, ...args, @@ -112,7 +115,7 @@ export function generateStep( } type Executors = { - [key in T]: (step: CompositionStep) => Promise; + [key in T]: (step: CompositionStepT) => Promise; }; type LockingFn = ( // TODO: Once the entire codebase is typescript, change @@ -130,13 +133,12 @@ interface CompositionCallbacks { fetchStart: () => void; fetchEnd: () => void; fetchTime: (time: number) => void; - stateReport: (state: Dictionary) => boolean; + stateReport: (state: DeviceReportFields) => void; bestDeltaSource: (image: Image, available: Image[]) => string | null; } export function getExecutors(app: { lockFn: LockingFn; - applications: ApplicationManager; callbacks: CompositionCallbacks; }) { const executors: Executors = { @@ -167,9 +169,6 @@ export function getExecutors(app: { async () => { await serviceManager.kill(step.current); app.callbacks.containerKilled(step.current.containerId); - if (_.get(step, ['options', 'removeImage'])) { - await images.removeByDockerId(step.current.config.image); - } }, ); }, @@ -209,7 +208,7 @@ export function getExecutors(app: { ); }, stopAll: async (step) => { - await app.applications.stopAll({ + await applicationManager.stopAll({ force: step.force, skipLock: step.skipLock, }); @@ -259,7 +258,7 @@ export function getExecutors(app: { // been downloaded ,and it's relevant mostly for // the legacy GET /v1/device endpoint that assumes // a single container app - await app.callbacks.stateReport({ update_downloaded: true }); + app.callbacks.stateReport({ update_downloaded: true }); } }, step.serviceName, @@ -292,6 +291,9 @@ export function getExecutors(app: { ensureSupervisorNetwork: async () => { networkManager.ensureSupervisorNetwork(); }, + noop: async () => { + /* async noop */ + }, }; return executors; diff --git a/src/compose/images.ts b/src/compose/images.ts index 17e4816c..42725919 100644 --- a/src/compose/images.ts +++ b/src/compose/images.ts @@ -20,6 +20,8 @@ import * as validation from '../lib/validation'; import * as logger from '../logger'; import { ImageDownloadBackoffError } from './errors'; +import type { Service } from './service'; + import log from '../lib/supervisor-console'; interface FetchProgressEvent { @@ -86,6 +88,23 @@ export const initialized = (async () => { }); })(); +type ServiceInfo = Pick< + Service, + 'imageName' | 'appId' | 'serviceId' | 'serviceName' | 'imageId' | 'releaseId' +>; +export function imageFromService(service: ServiceInfo): Image { + // We know these fields are defined because we create these images from target state + return { + name: service.imageName!, + appId: service.appId, + serviceId: service.serviceId!, + serviceName: service.serviceName!, + imageId: service.imageId!, + releaseId: service.releaseId!, + dependent: 0, + }; +} + export async function triggerFetch( image: Image, opts: FetchOptions, @@ -263,15 +282,10 @@ export async function getAvailable(): Promise { ); } -// TODO: Why does this need a Bluebird.try? -export function getDownloadingImageIds() { - return Bluebird.try(() => - _(volatileState) - .pickBy({ status: 'Downloading' }) - .keys() - .map(validation.checkInt) - .value(), - ); +export function getDownloadingImageIds(): number[] { + return _.keys(_.pickBy(volatileState, { status: 'Downloading' })).map((i) => + validation.checkInt(i), + ) as number[]; } export async function cleanupDatabase(): Promise { @@ -407,7 +421,8 @@ export async function inspectByName( imageName: string, ): Promise { try { - return await docker.getImage(imageName).inspect(); + const image = await docker.getImage(imageName); + return await image.inspect(); } catch (e) { if (NotFoundError(e)) { const digest = imageName.split('@')[1]; @@ -459,7 +474,9 @@ export function isSameImage( image1: Pick, image2: Pick, ): boolean { - return image1.name === image2.name || hasSameDigest(image1.name, image2.name); + return ( + image1?.name === image2?.name || hasSameDigest(image1?.name, image2?.name) + ); } export function normalise(imageName: string): Bluebird { diff --git a/src/compose/network.ts b/src/compose/network.ts index e809233f..92086d46 100644 --- a/src/compose/network.ts +++ b/src/compose/network.ts @@ -175,16 +175,23 @@ export class Network { network: { name: this.name, appId: this.appId }, }); - return Bluebird.resolve( - docker - .getNetwork(Network.generateDockerName(this.appId, this.name)) - .remove(), - ).tapCatch((error) => { - logger.logSystemEvent(logTypes.removeNetworkError, { - network: { name: this.name, appId: this.appId }, - error, + const networkName = Network.generateDockerName(this.appId, this.name); + + return Bluebird.resolve(docker.listNetworks()) + .then((networks) => networks.filter((n) => n.Name === networkName)) + .then(([network]) => { + if (!network) { + return Bluebird.resolve(); + } + return Bluebird.resolve( + docker.getNetwork(networkName).remove(), + ).tapCatch((error) => { + logger.logSystemEvent(logTypes.removeNetworkError, { + network: { name: this.name, appId: this.appId }, + error, + }); + }); }); - }); } public isEqualConfig(network: Network): boolean { diff --git a/src/compose/service-manager.ts b/src/compose/service-manager.ts index 4784cce1..5059f168 100644 --- a/src/compose/service-manager.ts +++ b/src/compose/service-manager.ts @@ -55,9 +55,9 @@ let listening = false; // we don't yet have an id) const volatileState: Dictionary> = {}; -export async function getAll( +export const getAll = async ( extraLabelFilters: string | string[] = [], -): Promise { +): Promise => { const filterLabels = ['supervised'].concat(extraLabelFilters); const containers = await listWithBothLabels(filterLabels); @@ -81,7 +81,7 @@ export async function getAll( }); return services.filter((s) => s != null) as Service[]; -} +}; export async function get(service: Service) { // Get the container ids for special network handling @@ -141,10 +141,7 @@ export async function getByDockerContainerId( return Service.fromDockerContainer(container); } -export async function updateMetadata( - service: Service, - metadata: { imageId: number; releaseId: number }, -) { +export async function updateMetadata(service: Service, target: Service) { const svc = await get(service); if (svc.containerId == null) { throw new InternalInconsistencyError( @@ -153,7 +150,7 @@ export async function updateMetadata( } await docker.getContainer(svc.containerId).rename({ - name: `${service.serviceName}_${metadata.imageId}_${metadata.releaseId}`, + name: `${service.serviceName}_${target.imageId}_${target.releaseId}`, }); } diff --git a/src/compose/service.ts b/src/compose/service.ts index 60984ed4..bcad6f9c 100644 --- a/src/compose/service.ts +++ b/src/compose/service.ts @@ -6,6 +6,7 @@ import * as path from 'path'; import * as conversions from '../lib/conversions'; import { checkInt } from '../lib/validation'; +import { InternalInconsistencyError } from '../lib/errors'; import { DockerPortOptions, PortMap } from './ports'; import { ConfigMap, @@ -27,19 +28,37 @@ import { EnvVarObject } from '../lib/types'; const SERVICE_NETWORK_MODE_REGEX = /service:\s*(.+)/; const CONTAINER_NETWORK_MODE_REGEX = /container:\s*(.+)/; +export type ServiceStatus = + | 'Stopping' + | 'Stopped' + | 'Running' + | 'Installing' + | 'Installed' + | 'Dead' + | 'paused' + | 'restarting' + | 'removing' + | 'exited'; + export class Service { - public appId: number | null; - public imageId: number | null; + public appId: number; + public imageId: number; public config: ServiceConfig; public serviceName: string | null; - public releaseId: number | null; - public serviceId: number | null; + public releaseId: number; + public serviceId: number; public imageName: string | null; public containerId: string | null; public dependsOn: string[] | null; - public status: string; + // This looks weird, and it is. The lowercase statuses come from Docker, + // except the dashboard takes these values and displays them on the dashboard. + // What we should be doin is defining these container statuses, and have the + // dashboard make these human readable instead. Until that happens we have + // this halfways state of some captalised statuses, and others coming directly + // from docker + public status: ServiceStatus; public createdAt: Date | null; private static configArrayFields: ServiceConfigArrayField[] = [ @@ -89,23 +108,25 @@ export class Service { appConfig = ComposeUtils.camelCaseConfig(appConfig); - const intOrNull = ( - val: string | number | null | undefined, - ): number | null => { - return checkInt(val) || null; - }; + if (!appConfig.appId) { + throw new InternalInconsistencyError('No app id for service'); + } + const appId = checkInt(appConfig.appId); + if (appId == null) { + throw new InternalInconsistencyError('Malformed app id for service'); + } // Seperate the application information from the docker // container configuration - service.imageId = intOrNull(appConfig.imageId); + service.imageId = parseInt(appConfig.imageId, 10); delete appConfig.imageId; service.serviceName = appConfig.serviceName; delete appConfig.serviceName; - service.appId = intOrNull(appConfig.appId); + service.appId = appId; delete appConfig.appId; - service.releaseId = intOrNull(appConfig.releaseId); + service.releaseId = parseInt(appConfig.releaseId, 10); delete appConfig.releaseId; - service.serviceId = intOrNull(appConfig.serviceId); + service.serviceId = parseInt(appConfig.serviceId, 10); delete appConfig.serviceId; service.imageName = appConfig.image; service.dependsOn = appConfig.dependsOn || null; @@ -282,7 +303,7 @@ export class Service { config.volumes = Service.extendAndSanitiseVolumes( config.volumes, options.imageInfo, - service.appId || 0, + service.appId, service.serviceName || '', ); @@ -439,7 +460,9 @@ export class Service { } else if (container.State.Status === 'dead') { svc.status = 'Dead'; } else { - svc.status = container.State.Status; + // We know this cast as fine as we represent all of the status available + // by docker in the ServiceStatus type + svc.status = container.State.Status as ServiceStatus; } svc.createdAt = new Date(container.Created); @@ -560,22 +583,44 @@ export class Service { tty: container.Config.Tty || false, }; - svc.appId = checkInt(svc.config.labels['io.balena.app-id']) || null; - svc.serviceId = checkInt(svc.config.labels['io.balena.service-id']) || null; + const appId = checkInt(svc.config.labels['io.balena.app-id']); + if (appId == null) { + throw new InternalInconsistencyError( + `Found a service with no appId! ${svc}`, + ); + } + svc.appId = appId; svc.serviceName = svc.config.labels['io.balena.service-name']; + svc.serviceId = parseInt(svc.config.labels['io.balena.service-id'], 10); + if (Number.isNaN(svc.serviceId)) { + throw new InternalInconsistencyError( + 'Attempt to build Service class from container with malformed labels', + ); + } const nameMatch = container.Name.match(/.*_(\d+)_(\d+)$/); + if (nameMatch == null) { + throw new InternalInconsistencyError( + 'Attempt to build Service class from container with malformed name', + ); + } - svc.imageId = nameMatch != null ? checkInt(nameMatch[1]) || null : null; - svc.releaseId = nameMatch != null ? checkInt(nameMatch[2]) || null : null; + svc.imageId = parseInt(nameMatch[1], 10); + svc.releaseId = parseInt(nameMatch[2], 10); svc.containerId = container.Id; return svc; } - public toComposeObject(): ServiceConfig { - // This isn't techinically correct as we do some changes - // to the configuration which we cannot reverse. We also - // represent the ports as a class, which isn't ideal + /** + * Here we try to reverse the fromComposeObject to the best of our ability, as + * this is used for the supervisor reporting it's own target state. Some of + * these values won't match in a 1-1 comparison, such as `devices`, as we lose + * some data about. + * + * @returns ServiceConfig + * @memberof Service + */ + public toComposeObject() { return this.config; } diff --git a/src/compose/types/service.ts b/src/compose/types/service.ts index f8fa8d1d..bdff1a3c 100644 --- a/src/compose/types/service.ts +++ b/src/compose/types/service.ts @@ -184,7 +184,7 @@ export interface ConfigMap { // is typescript export interface DeviceMetadata { imageInfo?: Dockerode.ImageInspectInfo; - uuid: string; + uuid: string | null; appName: string; version: string; deviceType: string; diff --git a/src/compose/update-strategies.ts b/src/compose/update-strategies.ts new file mode 100644 index 00000000..34256b1b --- /dev/null +++ b/src/compose/update-strategies.ts @@ -0,0 +1,57 @@ +import * as imageManager from './images'; +import Service from './service'; + +import { InternalInconsistencyError } from '../lib/errors'; +import { CompositionStep, generateStep } from './composition-steps'; + +export interface StrategyContext { + current: Service; + target: Service; + needsDownload: boolean; + dependenciesMetForStart: boolean; + dependenciesMetForKill: boolean; + needsSpecialKill: boolean; +} + +export function getStepsFromStrategy( + strategy: string, + context: StrategyContext, +): CompositionStep { + switch (strategy) { + case 'download-then-kill': + if (context.needsDownload) { + return generateStep('fetch', { + image: imageManager.imageFromService(context.target), + serviceName: context.target.serviceName!, + }); + } else if (context.dependenciesMetForKill) { + // We only kill when dependencies are already met, so that we minimize downtime + return generateStep('kill', { current: context.current }); + } else { + return { action: 'noop' }; + } + case 'kill-then-download': + case 'delete-then-download': + return generateStep('kill', { current: context.current }); + case 'hand-over': + if (context.needsDownload) { + return generateStep('fetch', { + image: imageManager.imageFromService(context.target), + serviceName: context.target.serviceName!, + }); + } else if (context.needsSpecialKill && context.dependenciesMetForKill) { + return generateStep('kill', { current: context.current }); + } else if (context.dependenciesMetForStart) { + return generateStep('handover', { + current: context.current, + target: context.target, + }); + } else { + return { action: 'noop' }; + } + default: + throw new InternalInconsistencyError( + `Invalid update strategy: ${strategy}`, + ); + } +} diff --git a/src/compose/utils.ts b/src/compose/utils.ts index 47a6090b..2736b1b6 100644 --- a/src/compose/utils.ts +++ b/src/compose/utils.ts @@ -309,6 +309,10 @@ export function formatDevice(deviceStr: string): DockerDevice { }; } +export function dockerDeviceToStr(device: DockerDevice): string { + return `${device.PathOnHost}:${device.PathInContainer}:${device.CgroupPermissions}`; +} + // TODO: Export these strings to a constant lib, to // enable changing them easily // Mutates service diff --git a/src/device-api/common.d.ts b/src/device-api/common.d.ts index 932bc748..b0d2d621 100644 --- a/src/device-api/common.d.ts +++ b/src/device-api/common.d.ts @@ -1,4 +1,3 @@ -import ApplicationManager from '../application-manager'; import { Service } from '../compose/service'; import { InstancedDeviceState } from '../types/state'; @@ -10,17 +9,9 @@ export interface ServiceAction { options: any; } -declare function doRestart( - applications: ApplicationManager, - appId: number, - force: boolean, -): Promise; +declare function doRestart(appId: number, force: boolean): Promise; -declare function doPurge( - applications: ApplicationManager, - appId: number, - force: boolean, -): Promise; +declare function doPurge(appId: number, force: boolean): Promise; declare function serviceAction( action: string, diff --git a/src/device-api/common.js b/src/device-api/common.js index 6411f60c..0996aaa8 100644 --- a/src/device-api/common.js +++ b/src/device-api/common.js @@ -3,26 +3,38 @@ import * as _ from 'lodash'; import { appNotFoundMessage } from '../lib/messages'; import * as logger from '../logger'; -import * as volumes from '../compose/volume-manager'; +import * as deviceState from '../device-state'; +import * as applicationManager from '../compose/application-manager'; +import * as volumeManager from '../compose/volume-manager'; +import { InternalInconsistencyError } from '../lib/errors'; -export function doRestart(applications, appId, force) { - const { _lockingIfNecessary, deviceState } = applications; +export async function doRestart(appId, force) { + await deviceState.initialized; + await applicationManager.initialized; - return _lockingIfNecessary(appId, { force }, () => - deviceState.getCurrentForComparison().then(function (currentState) { - const app = safeAppClone(currentState.local.apps[appId]); + const { lockingIfNecessary } = applicationManager; + + return lockingIfNecessary(appId, { force }, () => + deviceState.getCurrentState().then(function (currentState) { + if (currentState.local.apps?.[appId] == null) { + throw new InternalInconsistencyError( + `Application with ID ${appId} is not in the current state`, + ); + } + const allApps = currentState.local.apps; + + const app = allApps[appId]; const imageIds = _.map(app.services, 'imageId'); - applications.clearTargetVolatileForServices(imageIds); + applicationManager.clearTargetVolatileForServices(imageIds); - const stoppedApp = _.cloneDeep(app); - stoppedApp.services = []; - currentState.local.apps[appId] = stoppedApp; + const currentServices = app.services; + app.services = []; return deviceState .pausingApply(() => deviceState .applyIntermediateTarget(currentState, { skipLock: true }) .then(function () { - currentState.local.apps[appId] = app; + app.services = currentServices; return deviceState.applyIntermediateTarget(currentState, { skipLock: true, }); @@ -33,25 +45,33 @@ export function doRestart(applications, appId, force) { ); } -export function doPurge(applications, appId, force) { - const { _lockingIfNecessary, deviceState } = applications; +export async function doPurge(appId, force) { + await deviceState.initialized; + await applicationManager.initialized; + + const { lockingIfNecessary } = applicationManager; logger.logSystemMessage( `Purging data for app ${appId}`, { appId }, 'Purge data', ); - return _lockingIfNecessary(appId, { force }, () => - deviceState.getCurrentForComparison().then(function (currentState) { - if (currentState.local.apps[appId] == null) { + return lockingIfNecessary(appId, { force }, () => + deviceState.getCurrentState().then(function (currentState) { + const allApps = currentState.local.apps; + + if (allApps?.[appId] == null) { throw new Error(appNotFoundMessage); } - const app = safeAppClone(currentState.local.apps[appId]); - const purgedApp = _.cloneDeep(app); - purgedApp.services = []; - purgedApp.volumes = {}; - currentState.local.apps[appId] = purgedApp; + const app = allApps[appId]; + + const currentServices = app.services; + const currentVolumes = app.volumes; + + app.services = []; + app.volumes = {}; + return deviceState .pausingApply(() => deviceState @@ -61,12 +81,13 @@ export function doPurge(applications, appId, force) { // remove the volumes, we must do this here, as the // application-manager will not remove any volumes // which are part of an active application - return Bluebird.each(volumes.getAllByAppId(appId), (vol) => + return Bluebird.each(volumeManager.getAllByAppId(appId), (vol) => vol.remove(), ); }) - .then(function () { - currentState.local.apps[appId] = app; + .then(() => { + app.services = currentServices; + app.volumes = currentVolumes; return deviceState.applyIntermediateTarget(currentState, { skipLock: true, }); diff --git a/src/device-api/v1.js b/src/device-api/v1.js index 2cdde800..1209f07b 100644 --- a/src/device-api/v1.js +++ b/src/device-api/v1.js @@ -4,9 +4,12 @@ import * as _ from 'lodash'; import * as eventTracker from '../event-tracker'; import * as constants from '../lib/constants'; import { checkInt, checkTruthy } from '../lib/validation'; -import { doRestart, doPurge, serviceAction } from './common'; +import { doRestart, doPurge } from './common'; -export const createV1Api = function (router, applications) { +import * as applicationManager from '../compose/application-manager'; +import { generateStep } from '../compose/composition-steps'; + +export const createV1Api = function (router) { router.post('/v1/restart', function (req, res, next) { const appId = checkInt(req.body.appId); const force = checkTruthy(req.body.force) ?? false; @@ -14,7 +17,7 @@ export const createV1Api = function (router, applications) { if (appId == null) { return res.status(400).send('Missing app id'); } - return doRestart(applications, appId, force) + return doRestart(appId, force) .then(() => res.status(200).send('OK')) .catch(next); }); @@ -25,13 +28,18 @@ export const createV1Api = function (router, applications) { if (appId == null) { return res.status(400).send('Missing app id'); } - return applications - .getCurrentApp(appId) - .then(function (app) { - let service = app?.services?.[0]; - if (service == null) { + + return applicationManager + .getCurrentApps() + .then(function (apps) { + if (apps[appId] == null) { return res.status(400).send('App not found'); } + const app = apps[appId]; + let service = app.services[0]; + if (service == null) { + return res.status(400).send('No services on app'); + } if (app.services.length > 1) { return res .status(400) @@ -39,23 +47,21 @@ export const createV1Api = function (router, applications) { 'Some v1 endpoints are only allowed on single-container apps', ); } - applications.setTargetVolatileForService(service.imageId, { + applicationManager.setTargetVolatileForService(service.imageId, { running: action !== 'stop', }); - return applications - .executeStepAction( - serviceAction(action, service.serviceId, service, service, { - wait: true, - }), - { force }, - ) + return applicationManager + .executeStep(generateStep(action, { current: service, wait: true }), { + force, + }) .then(function () { if (action === 'stop') { return service; } // We refresh the container id in case we were starting an app with no container yet - return applications.getCurrentApp(appId).then(function (app2) { - service = app2?.services?.[0]; + return applicationManager.getCurrentApps().then(function (apps2) { + const app2 = apps2[appId]; + service = app2.services[0]; if (service == null) { throw new Error('App not found after running action'); } @@ -82,9 +88,10 @@ export const createV1Api = function (router, applications) { return res.status(400).send('Missing app id'); } return Promise.join( - applications.getCurrentApp(appId), - applications.getStatus(), - function (app, status) { + applicationManager.getCurrentApps(), + applicationManager.getStatus(), + function (apps, status) { + const app = apps[appId]; const service = app?.services?.[0]; if (service == null) { return res.status(400).send('App not found'); @@ -100,9 +107,9 @@ export const createV1Api = function (router, applications) { const appToSend = { appId, containerId: service.containerId, - env: _.omit(service.environment, constants.privateAppEnvVars), + env: _.omit(service.config.environment, constants.privateAppEnvVars), releaseId: service.releaseId, - imageId: service.image, + imageId: service.config.image, }; if (status.commit != null) { appToSend.commit = status.commit; @@ -119,7 +126,7 @@ export const createV1Api = function (router, applications) { const errMsg = 'Invalid or missing appId'; return res.status(400).send(errMsg); } - return doPurge(applications, appId, force) + return doPurge(appId, force) .then(() => res.status(200).json({ Data: 'OK', Error: '' })) .catch(next); }); diff --git a/src/device-api/v2.ts b/src/device-api/v2.ts index 42baac25..b61bfb28 100644 --- a/src/device-api/v2.ts +++ b/src/device-api/v2.ts @@ -2,9 +2,14 @@ import * as Bluebird from 'bluebird'; import { NextFunction, Request, Response, Router } from 'express'; import * as _ from 'lodash'; -import { ApplicationManager } from '../application-manager'; import * as deviceState from '../device-state'; import * as apiBinder from '../api-binder'; +import * as applicationManager from '../compose/application-manager'; +import { + CompositionStepAction, + generateStep, +} from '../compose/composition-steps'; +import { getApp } from '../device-state/db-format'; import { Service } from '../compose/service'; import Volume from '../compose/volume'; import * as config from '../config'; @@ -24,16 +29,14 @@ import log from '../lib/supervisor-console'; import supervisorVersion = require('../lib/supervisor-version'); import { checkInt, checkTruthy } from '../lib/validation'; import { isVPNActive } from '../network'; -import { doPurge, doRestart, safeStateClone, serviceAction } from './common'; - -export function createV2Api(router: Router, applications: ApplicationManager) { - const { _lockingIfNecessary } = applications; +import { doPurge, doRestart, safeStateClone } from './common'; +export function createV2Api(router: Router) { const handleServiceAction = ( req: Request, res: Response, next: NextFunction, - action: any, + action: CompositionStepAction, ): Resolvable => { const { imageId, serviceName, force } = req.body; const appId = checkInt(req.params.appId); @@ -45,10 +48,11 @@ export function createV2Api(router: Router, applications: ApplicationManager) { return; } - return _lockingIfNecessary(appId, { force }, () => { - return applications - .getCurrentApp(appId) - .then((app) => { + return applicationManager.lockingIfNecessary(appId, { force }, () => { + return Promise.all([applicationManager.getCurrentApps(), getApp(appId)]) + .then(([apps, targetApp]) => { + const app = apps[appId]; + if (app == null) { res.status(404).send(appNotFoundMessage); return; @@ -62,27 +66,41 @@ export function createV2Api(router: Router, applications: ApplicationManager) { } let service: Service | undefined; + let targetService: Service | undefined; if (imageId != null) { service = _.find(app.services, (svc) => svc.imageId === imageId); + targetService = _.find( + targetApp.services, + (svc) => svc.imageId === imageId, + ); } else { service = _.find( app.services, (svc) => svc.serviceName === serviceName, ); + targetService = _.find( + targetApp.services, + (svc) => svc.serviceName === serviceName, + ); } if (service == null) { res.status(404).send(serviceNotFoundMessage); return; } - applications.setTargetVolatileForService(service.imageId!, { + + applicationManager.setTargetVolatileForService(service.imageId!, { running: action !== 'stop', }); - return applications - .executeStepAction( - serviceAction(action, service.serviceId!, service, service, { + return applicationManager + .executeStep( + generateStep(action, { + current: service, + target: targetService, wait: true, }), - { skipLock: true }, + { + skipLock: true, + }, ) .then(() => { res.status(200).send('OK'); @@ -107,7 +125,7 @@ export function createV2Api(router: Router, applications: ApplicationManager) { }); } - return doPurge(applications, appId, force) + return doPurge(appId, force) .then(() => { res.status(200).send('OK'); }) @@ -142,7 +160,7 @@ export function createV2Api(router: Router, applications: ApplicationManager) { }); } - return doRestart(applications, appId, force) + return doRestart(appId, force) .then(() => { res.status(200).send('OK'); }) @@ -241,7 +259,7 @@ export function createV2Api(router: Router, applications: ApplicationManager) { // Query device for all applications let apps: any; try { - apps = await applications.getStatus(); + apps = await applicationManager.getStatus(); } catch (e) { log.error(e.message); return res.status(500).json({ @@ -336,7 +354,7 @@ export function createV2Api(router: Router, applications: ApplicationManager) { if (id in serviceNameCache) { return serviceNameCache[id]; } else { - const name = await applications.serviceNameFromId(id); + const name = await applicationManager.serviceNameFromId(id); serviceNameCache[id] = name; return name; } @@ -460,6 +478,7 @@ export function createV2Api(router: Router, applications: ApplicationManager) { tags, }); } catch (e) { + log.error(e); res.status(500).json({ status: 'failed', message: e.message, @@ -482,11 +501,13 @@ export function createV2Api(router: Router, applications: ApplicationManager) { }); router.get('/v2/cleanup-volumes', async (_req, res) => { - const targetState = await applications.getTargetApps(); + const targetState = await applicationManager.getTargetApps(); const referencedVolumes: string[] = []; - _.each(targetState, (app) => { - _.each(app.volumes, (vol) => { - referencedVolumes.push(Volume.generateDockerName(vol.appId, vol.name)); + _.each(targetState, (app, appId) => { + _.each(app.volumes, (_volume, volumeName) => { + referencedVolumes.push( + Volume.generateDockerName(parseInt(appId, 10), volumeName), + ); }); }); await volumeManager.removeOrphanedVolumes(referencedVolumes); diff --git a/src/device-state.ts b/src/device-state.ts index 9909e9a1..2dec6371 100644 --- a/src/device-state.ts +++ b/src/device-state.ts @@ -13,7 +13,7 @@ import * as db from './db'; import * as logger from './logger'; import { - CompositionStep, + CompositionStepT, CompositionStepAction, } from './compose/composition-steps'; import { loadTargetFromFile } from './device-state/preload'; @@ -26,7 +26,7 @@ import * as updateLock from './lib/update-lock'; import * as validation from './lib/validation'; import * as network from './network'; -import { ApplicationManager } from './application-manager'; +import * as applicationManager from './compose/application-manager'; import * as deviceConfig from './device-config'; import { ConfigStep } from './device-config'; import { log } from './lib/supervisor-console'; @@ -35,7 +35,9 @@ import { DeviceStatus, InstancedDeviceState, TargetState, + InstancedAppState, } from './types/state'; +import * as dbFormat from './device-state/db-format'; function validateLocalState(state: any): asserts state is TargetState['local'] { if (state.name != null) { @@ -169,7 +171,7 @@ function createDeviceStateRouter() { } }); - router.use(applications.router); + router.use(applicationManager.router); return router; } @@ -214,17 +216,14 @@ type DeviceStateStep = } | { action: 'shutdown' } | { action: 'noop' } - | CompositionStep + | CompositionStepT | ConfigStep; -// export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmitter) { -export const applications = new ApplicationManager(); - let currentVolatile: DeviceReportFields = {}; const writeLock = updateLock.writeLock; const readLock = updateLock.readLock; let maxPollTime: number; -let intermediateTarget: TargetState | null = null; +let intermediateTarget: InstancedDeviceState | null = null; let applyBlocker: Nullable>; let cancelDelay: null | (() => void) = null; @@ -239,7 +238,6 @@ export let connected: boolean; export let lastSuccessfulUpdate: number | null = null; export let router: express.Router; -createDeviceStateRouter(); events.on('error', (err) => log.error('deviceState error: ', err)); events.on('apply-target-state-end', function (err) { @@ -255,10 +253,13 @@ events.on('apply-target-state-end', function (err) { return deviceConfig.resetRateLimits(); } }); -applications.on('change', (d) => reportCurrentState(d)); export const initialized = (async () => { await config.initialized; + await applicationManager.initialized; + + applicationManager.on('change', (d) => reportCurrentState(d)); + createDeviceStateRouter(); config.on('change', (changedConfig) => { if (changedConfig.loggingEnabled != null) { @@ -288,20 +289,20 @@ export async function healthcheck() { const cycleTime = process.hrtime(lastApplyStart); const cycleTimeMs = cycleTime[0] * 1000 + cycleTime[1] / 1e6; const cycleTimeWithinInterval = - cycleTimeMs - applications.timeSpentFetching < 2 * maxPollTime; + cycleTimeMs - applicationManager.timeSpentFetching < 2 * maxPollTime; // Check if target is healthy const applyTargetHealthy = !applyInProgress || - applications.fetchesInProgress > 0 || + applicationManager.fetchesInProgress > 0 || cycleTimeWithinInterval; if (!applyTargetHealthy) { log.info( stripIndent` - Healthcheck failure - Atleast ONE of the following conditions must be true: + Healthcheck failure - At least ONE of the following conditions must be true: - No applyInProgress ? ${!(applyInProgress === true)} - - fetchesInProgress ? ${applications.fetchesInProgress > 0} + - fetchesInProgress ? ${applicationManager.fetchesInProgress > 0} - cycleTimeWithinInterval ? ${cycleTimeWithinInterval}`, ); } @@ -344,7 +345,7 @@ async function saveInitialConfig() { } export async function loadInitialState() { - await applications.init(); + await applicationManager.initialized; const conf = await config.getMany([ 'initialConfigSaved', @@ -387,7 +388,7 @@ export async function loadInitialState() { update_downloaded: false, }); - const targetApps = await applications.getTargetApps(); + const targetApps = await applicationManager.getTargetApps(); if (!conf.provisioned || (_.isEmpty(targetApps) && !conf.targetStateSet)) { try { await loadTargetFromFile(null); @@ -429,13 +430,19 @@ const writeLockTarget = () => writeLock('target').disposer((release) => release()); const inferStepsLock = () => writeLock('inferSteps').disposer((release) => release()); -function usingReadLockTarget(fn: () => any) { +function usingReadLockTarget any, U extends ReturnType>( + fn: T, +): Bluebird> { return Bluebird.using(readLockTarget, () => fn()); } -function usingWriteLockTarget(fn: () => any) { +function usingWriteLockTarget any, U extends ReturnType>( + fn: T, +): Bluebird> { return Bluebird.using(writeLockTarget, () => fn()); } -function usingInferStepsLock(fn: () => any) { +function usingInferStepsLock any, U extends ReturnType>( + fn: T, +): Bluebird> { return Bluebird.using(inferStepsLock, () => fn()); } @@ -462,14 +469,14 @@ export async function setTarget(target: TargetState, localSource?: boolean) { await deviceConfig.setTarget(target.local.config, trx); if (localSource || apiEndpoint == null) { - await applications.setTarget( + await applicationManager.setTarget( target.local.apps, target.dependent, 'local', trx, ); } else { - await applications.setTarget( + await applicationManager.setTarget( target.local.apps, target.dependent, apiEndpoint, @@ -488,22 +495,22 @@ export function getTarget({ > { return usingReadLockTarget(async () => { if (intermediate) { - return intermediateTarget; + return intermediateTarget!; } return { local: { name: await config.get('name'), config: await deviceConfig.getTarget({ initial }), - apps: await applications.getTargetApps(), + apps: await dbFormat.getApps(), }, - dependent: await applications.getDependentTargets(), + dependent: await applicationManager.getDependentTargets(), }; - }) as Bluebird; + }); } export async function getStatus(): Promise { - const appsStatus = await applications.getStatus(); + const appsStatus = await applicationManager.getStatus(); const theState: DeepPartial = { local: {}, dependent: {}, @@ -524,8 +531,8 @@ export async function getCurrentForComparison(): Promise< const [name, devConfig, apps, dependent] = await Promise.all([ config.get('name'), deviceConfig.getCurrent(), - applications.getCurrentForComparison(), - applications.getDependentState(), + applicationManager.getCurrentAppsForReport(), + applicationManager.getDependentState(), ]); return { local: { @@ -538,7 +545,27 @@ export async function getCurrentForComparison(): Promise< }; } -export function reportCurrentState(newState: DeviceReportFields = {}) { +export async function getCurrentState(): Promise { + const [name, devConfig, apps, dependent] = await Promise.all([ + config.get('name'), + deviceConfig.getCurrent(), + applicationManager.getCurrentApps(), + applicationManager.getDependentState(), + ]); + + return { + local: { + name, + config: devConfig, + apps, + }, + dependent, + }; +} + +export function reportCurrentState( + newState: DeviceReportFields & Partial = {}, +) { if (newState == null) { newState = {}; } @@ -547,7 +574,7 @@ export function reportCurrentState(newState: DeviceReportFields = {}) { } export async function reboot(force?: boolean, skipLock?: boolean) { - await applications.stopAll({ force, skipLock }); + await applicationManager.stopAll({ force, skipLock }); logger.logSystemMessage('Rebooting', {}, 'Reboot'); const $reboot = await dbus.reboot(); shuttingDown = true; @@ -556,7 +583,7 @@ export async function reboot(force?: boolean, skipLock?: boolean) { } export async function shutdown(force?: boolean, skipLock?: boolean) { - await applications.stopAll({ force, skipLock }); + await applicationManager.stopAll({ force, skipLock }); logger.logSystemMessage('Shutting down', {}, 'Shutdown'); const $shutdown = await dbus.shutdown(); shuttingDown = true; @@ -576,8 +603,8 @@ export async function executeStepAction( await deviceConfig.executeStepAction(step as ConfigStep, { initial, }); - } else if (_.includes(applications.validActions, step.action)) { - return applications.executeStepAction(step as any, { + } else if (_.includes(applicationManager.validActions, step.action)) { + return applicationManager.executeStep(step as any, { force, skipLock, }); @@ -691,17 +718,13 @@ export const applyTarget = async ({ if (!intermediate) { await applyBlocker; } - await applications.localModeSwitchCompletion(); + await applicationManager.localModeSwitchCompletion(); return usingInferStepsLock(async () => { const [currentState, targetState] = await Promise.all([ getCurrentForComparison(), getTarget({ initial, intermediate }), ]); - const extraState = await applications.getExtraStateForComparison( - currentState, - targetState, - ); const deviceConfigSteps = await deviceConfig.getRequiredSteps( currentState, targetState, @@ -718,11 +741,8 @@ export const applyTarget = async ({ backoff = false; steps = deviceConfigSteps; } else { - const appSteps = await applications.getRequiredSteps( - currentState, - targetState, - extraState, - intermediate, + const appSteps = await applicationManager.getRequiredSteps( + targetState.local.apps, ); if (_.isEmpty(appSteps)) { @@ -742,7 +762,7 @@ export const applyTarget = async ({ emitAsync('apply-target-state-end', null); if (!intermediate) { log.debug('Finished applying target state'); - applications.timeSpentFetching = 0; + applicationManager.resetTimeSpentFetching(); failedUpdates = 0; lastSuccessfulUpdate = Date.now(); reportCurrentState({ @@ -878,10 +898,11 @@ export function triggerApplyTarget({ } export function applyIntermediateTarget( - intermediate: TargetState, + intermediate: InstancedDeviceState, { force = false, skipLock = false } = {}, ) { - intermediateTarget = _.cloneDeep(intermediate); + // TODO: Make sure we don't accidentally overwrite this + intermediateTarget = intermediate; return applyTarget({ intermediate: true, force, skipLock }).then(() => { intermediateTarget = null; }); diff --git a/src/device-state/db-format.ts b/src/device-state/db-format.ts index 3f4d13ba..e0b1fc85 100644 --- a/src/device-state/db-format.ts +++ b/src/device-state/db-format.ts @@ -1,26 +1,17 @@ -import { promises as fs } from 'fs'; -import * as path from 'path'; import * as _ from 'lodash'; -import type { ImageInspectInfo } from 'dockerode'; -import * as config from '../config'; import * as db from '../db'; import * as targetStateCache from '../device-state/target-state-cache'; -import constants = require('../lib/constants'); -import { pathExistsOnHost } from '../lib/fs-utils'; -import * as dockerUtils from '../lib/docker-utils'; -import { NotFoundError } from '../lib/errors'; -import Service from '../compose/service'; -import Network from '../compose/network'; -import Volume from '../compose/volume'; -import type { - DeviceMetadata, - ServiceComposeConfig, -} from '../compose/types/service'; +import App from '../compose/app'; import * as images from '../compose/images'; -import { InstancedAppState, TargetApplication } from '../types/state'; +import { + InstancedAppState, + TargetApplication, + TargetApplications, + TargetApplicationService, +} from '../types/state'; import { checkInt } from '../lib/validation'; type InstancedApp = InstancedAppState[0]; @@ -31,7 +22,7 @@ type InstancedApp = InstancedAppState[0]; // requiring that data here export async function getApp(id: number): Promise { const dbApp = await getDBEntry(id); - return await buildApp(dbApp); + return await App.fromTargetState(dbApp); } export async function getApps(): Promise { @@ -39,110 +30,12 @@ export async function getApps(): Promise { const apps: InstancedAppState = {}; await Promise.all( dbApps.map(async (app) => { - apps[app.appId] = await buildApp(app); + apps[app.appId] = await App.fromTargetState(app); }), ); return apps; } -async function buildApp(dbApp: targetStateCache.DatabaseApp) { - const volumes = _.mapValues(JSON.parse(dbApp.volumes) ?? {}, (conf, name) => { - if (conf == null) { - conf = {}; - } - if (conf.labels == null) { - conf.labels = {}; - } - return Volume.fromComposeObject(name, dbApp.appId, conf); - }); - - const networks = _.mapValues( - JSON.parse(dbApp.networks) ?? {}, - (conf, name) => { - if (conf == null) { - conf = {}; - } - return Network.fromComposeObject(name, dbApp.appId, conf); - }, - ); - - const [ - opts, - supervisorApiHost, - hostPathExists, - hostnameOnHost, - ] = await Promise.all([ - config.get('extendedEnvOptions'), - dockerUtils - .getNetworkGateway(constants.supervisorNetworkInterface) - .catch(() => '127.0.0.1'), - (async () => ({ - firmware: await pathExistsOnHost('/lib/firmware'), - modules: await pathExistsOnHost('/lib/modules'), - }))(), - (async () => - _.trim( - await fs.readFile( - path.join(constants.rootMountPoint, '/etc/hostname'), - 'utf8', - ), - ))(), - ]); - - const svcOpts = { - appName: dbApp.name, - supervisorApiHost, - hostPathExists, - hostnameOnHost, - ...opts, - }; - - // In the db, the services are an array, but here we switch them to an - // object so that they are consistent - const services = _.keyBy( - await Promise.all( - (JSON.parse(dbApp.services) ?? []).map( - async (svc: ServiceComposeConfig) => { - // Try to fill the image id if the image is downloaded - let imageInfo: ImageInspectInfo | undefined; - try { - imageInfo = await images.inspectByName(svc.image); - } catch (e) { - if (!NotFoundError(e)) { - throw e; - } - } - - const thisSvcOpts = { - ...svcOpts, - imageInfo, - serviceName: svc.serviceName, - }; - // We force the casting here as we know that the UUID exists, but the typings do - // not - return Service.fromComposeObject( - svc, - (thisSvcOpts as unknown) as DeviceMetadata, - ); - }, - ), - ), - 'serviceId', - ) as Dictionary; - - return { - appId: dbApp.appId, - commit: dbApp.commit, - releaseId: dbApp.releaseId, - name: dbApp.name, - source: dbApp.source, - - services, - volumes, - networks, - }; -} - export async function setApps( apps: { [appId: number]: TargetApplication }, source: string, @@ -179,6 +72,36 @@ export async function setApps( await targetStateCache.setTargetApps(dbApps, trx); } +export async function getTargetJson(): Promise { + const dbApps = await getDBEntry(); + const apps: TargetApplications = {}; + await Promise.all( + dbApps.map(async (app) => { + const parsedServices = JSON.parse(app.services); + + const services = _(parsedServices) + .keyBy('serviceId') + .mapValues( + (svc: TargetApplicationService) => + _.omit(svc, 'commit') as TargetApplicationService, + ) + .value(); + + apps[app.appId] = { + // We remove the id as this is the supervisor database id, and the + // source is internal and isn't used except for when we fetch the target + // state + ..._.omit(app, ['id', 'source']), + services, + networks: JSON.parse(app.networks), + volumes: JSON.parse(app.volumes), + // We can add this cast because it's required in the db + } as TargetApplication; + }), + ); + return apps; +} + function getDBEntry(): Promise; function getDBEntry(appId: number): Promise; async function getDBEntry(appId?: number) { diff --git a/src/device-state/preload.ts b/src/device-state/preload.ts index 9b81b9fd..9e58cdd9 100644 --- a/src/device-state/preload.ts +++ b/src/device-state/preload.ts @@ -1,7 +1,7 @@ import * as _ from 'lodash'; import { fs } from 'mz'; -import { Image } from '../compose/images'; +import { Image, imageFromService } from '../compose/images'; import * as deviceState from '../device-state'; import * as config from '../config'; import * as deviceConfig from '../device-config'; @@ -64,11 +64,11 @@ export async function loadTargetFromFile( imageName: service.image, serviceName: service.serviceName, imageId: service.imageId, - serviceId, + serviceId: parseInt(serviceId, 10), releaseId: app.releaseId, - appId, + appId: parseInt(appId, 10), }; - imgs.push(deviceState.applications.imageForService(svc)); + imgs.push(imageFromService(svc)); } } diff --git a/src/lib/migration.ts b/src/lib/migration.ts index 91c155e6..49b57583 100644 --- a/src/lib/migration.ts +++ b/src/lib/migration.ts @@ -14,6 +14,7 @@ import * as db from '../db'; import * as volumeManager from '../compose/volume-manager'; import * as serviceManager from '../compose/service-manager'; import * as deviceState from '../device-state'; +import * as applicationManager from '../compose/application-manager'; import * as constants from '../lib/constants'; import { BackupError, @@ -258,7 +259,8 @@ export async function normaliseLegacyDatabase() { await serviceManager.killAllLegacy(); log.debug('Migrating legacy app volumes'); - const targetApps = await deviceState.applications.getTargetApps(); + await applicationManager.initialized; + const targetApps = await applicationManager.getTargetApps(); for (const appId of _.keys(targetApps)) { await volumeManager.createFromLegacy(parseInt(appId, 10)); diff --git a/src/proxyvisor.js b/src/proxyvisor.js index f5865670..08ae0fe1 100644 --- a/src/proxyvisor.js +++ b/src/proxyvisor.js @@ -20,7 +20,12 @@ import * as db from './db'; import * as config from './config'; import * as dockerUtils from './lib/docker-utils'; import * as logger from './logger'; +import { InternalInconsistencyError } from './lib/errors'; + +import * as apiBinder from './api-binder'; import * as apiHelper from './lib/api-helper'; +import * as dbFormat from './device-state/db-format'; +import * as deviceConfig from './device-config'; const mkdirpAsync = Promise.promisify(mkdirp); @@ -117,7 +122,7 @@ const createProxyvisorRouter = function (proxyvisor) { belongs_to__application: req.body.appId, device_type, }; - return proxyvisor.apiBinder + return apiBinder .provisionDependentDevice(d) .then(function (dev) { // If the response has id: null then something was wrong in the request @@ -278,10 +283,7 @@ const createProxyvisorRouter = function (proxyvisor) { } return Promise.try(function () { if (!_.isEmpty(fieldsToUpdateOnAPI)) { - return proxyvisor.apiBinder.patchDevice( - device.deviceId, - fieldsToUpdateOnAPI, - ); + return apiBinder.patchDevice(device.deviceId, fieldsToUpdateOnAPI); } }) .then(() => @@ -348,8 +350,7 @@ const createProxyvisorRouter = function (proxyvisor) { }; export class Proxyvisor { - constructor({ applications }) { - this.bindToAPI = this.bindToAPI.bind(this); + constructor() { this.executeStepAction = this.executeStepAction.bind(this); this.getCurrentStates = this.getCurrentStates.bind(this); this.normaliseDependentAppForDB = this.normaliseDependentAppForDB.bind( @@ -364,14 +365,13 @@ export class Proxyvisor { this.sendUpdate = this.sendUpdate.bind(this); this.sendDeleteHook = this.sendDeleteHook.bind(this); this.sendUpdates = this.sendUpdates.bind(this); - this.applications = applications; this.acknowledgedState = {}; this.lastRequestForDevice = {}; this.router = createProxyvisorRouter(this); this.actionExecutors = { updateDependentTargets: (step) => { - return config - .getMany(['currentApiKey', 'apiTimeout']) + return config.initialized + .then(() => config.getMany(['currentApiKey', 'apiTimeout'])) .then(({ currentApiKey, apiTimeout }) => { // - take each of the step.devices and update dependentDevice with it (targetCommit, targetEnvironment, targetConfig) // - if update returns 0, then use APIBinder to fetch the device, then store it to the db @@ -407,9 +407,25 @@ export class Proxyvisor { } // If the device is not in the DB it means it was provisioned externally // so we need to fetch it. + if (apiBinder.balenaApi == null) { + throw new InternalInconsistencyError( + 'proxyvisor called fetchDevice without an initialized API client', + ); + } + return apiHelper - .fetchDevice(this.apiBinder.balenaApi, uuid, currentApiKey, apiTimeout) + .fetchDevice( + apiBinder.balenaApi, + uuid, + currentApiKey, + apiTimeout, + ) .then((dev) => { + if (dev == null) { + throw new InternalInconsistencyError( + `Could not fetch a device with UUID: ${uuid}`, + ); + } const deviceForDB = { uuid, appId, @@ -489,10 +505,6 @@ export class Proxyvisor { this.validActions = _.keys(this.actionExecutors); } - bindToAPI(apiBinder) { - return (this.apiBinder = apiBinder); - } - executeStepAction(step) { return Promise.try(() => { if (this.actionExecutors[step.action] == null) { @@ -695,15 +707,15 @@ export class Proxyvisor { imagesInUse(current, target) { const images = []; - if (current.dependent?.apps != null) { - for (const app of current.dependent.apps) { + if (current?.dependent?.apps != null) { + _.forEach(current.dependent.apps, (app) => { images.push(app.image); - } + }); } - if (target?.dependent.apps != null) { - for (const app of target.dependent.apps) { + if (target?.dependent?.apps != null) { + _.forEach(target.dependent.apps, (app) => { images.push(app.image); - } + }); } return images; } @@ -900,12 +912,10 @@ export class Proxyvisor { .models('dependentApp') .select('parentApp') .where({ appId }) - .then(([{ parentApp }]) => { - return this.applications.getTargetApp(parentApp); - }) + .then(([{ parentApp }]) => dbFormat.getApp(parseInt(parentApp, 10))) .then((parentApp) => { - return Promise.map(parentApp?.services ?? [], (service) => { - return dockerUtils.getImageEnv(service.image); + return Promise.map(parentApp.services ?? [], (service) => { + return dockerUtils.getImageEnv(service.config.image); }).then(function (imageEnvs) { const imageHookAddresses = _.map( imageEnvs, @@ -918,11 +928,16 @@ export class Proxyvisor { return addr; } } - return ( - parentApp?.config?.BALENA_DEPENDENT_DEVICES_HOOK_ADDRESS ?? - parentApp?.config?.RESIN_DEPENDENT_DEVICES_HOOK_ADDRESS ?? - `${constants.proxyvisorHookReceiver}/v1/devices/` - ); + // If we don't find the hook address in the images, we take it from + // the global config + return deviceConfig + .getTarget() + .then( + (target) => + target.BALENA_DEPENDENT_DEVICES_HOOK_ADDRESS ?? + target.RESIN_DEPENDENT_DEVICES_HOOK_ADDRESS ?? + `${constants.proxyvisorHookReceiver}/v1/devices/`, + ); }); }); } diff --git a/src/supervisor.ts b/src/supervisor.ts index 83af8129..2e7fb0bb 100644 --- a/src/supervisor.ts +++ b/src/supervisor.ts @@ -33,20 +33,6 @@ const startupConfigFields: config.ConfigKey[] = [ export class Supervisor { private api: SupervisorAPI; - public constructor() { - // FIXME: rearchitect proxyvisor to avoid this circular dependency - // by storing current state and having the APIBinder query and report it / provision devices - deviceState.applications.proxyvisor.bindToAPI(apiBinder); - - this.api = new SupervisorAPI({ - routers: [apiBinder.router, deviceState.router], - healthchecks: [ - apiBinder.healthcheck, - deviceState.healthcheck, - ], - }); - } - public async init() { log.info(`Supervisor v${version} starting up...`); @@ -82,6 +68,10 @@ export class Supervisor { await deviceState.loadInitialState(); log.info('Starting API server'); + this.api = new SupervisorAPI({ + routers: [apiBinder.router, deviceState.router], + healthchecks: [apiBinder.healthcheck, deviceState.healthcheck], + }); this.api.listen(conf.listenPort, conf.apiTimeout); deviceState.on('shutdown', () => this.api.stop()); diff --git a/src/types/image.ts b/src/types/image.ts deleted file mode 100644 index 9c05eecc..00000000 --- a/src/types/image.ts +++ /dev/null @@ -1,15 +0,0 @@ -export interface Image { - id: number; - name: string; - appId: number; - serviceId: number; - serviceName: string; - imageId: number; - releaseId: number; - dependent: number; - dockerImageId: string; - status: string; - downloadProgress: number | null; -} - -export default Image; diff --git a/src/types/state.ts b/src/types/state.ts index 90aa0c0f..3dfd2533 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -1,10 +1,9 @@ import { ComposeNetworkConfig } from '../compose/types/network'; import { ServiceComposeConfig } from '../compose/types/service'; -import Volume, { ComposeVolumeConfig } from '../compose/volume'; +import { ComposeVolumeConfig } from '../compose/volume'; import { EnvVarObject, LabelObject } from '../lib/types'; -import Network from '../compose/network'; -import Service from '../compose/service'; +import App from '../compose/app'; export type DeviceReportFields = Partial<{ api_port: number; @@ -24,6 +23,7 @@ export type DeviceReportFields = Partial<{ mac_address: string | null; }>; +// This is the state that is sent to the cloud export interface DeviceStatus { local?: { config?: Dictionary; @@ -93,26 +93,12 @@ export interface TargetState { export type LocalTargetState = TargetState['local']; export type TargetApplications = LocalTargetState['apps']; export type TargetApplication = LocalTargetState['apps'][0]; +export type TargetApplicationService = TargetApplication['services'][0]; export type AppsJsonFormat = Omit & { pinDevice?: boolean; }; -// This structure is the internal representation of both -// target and current state. We create instances of compose -// objects and these are what the state engine uses to -// detect what it should do to move between them -export interface InstancedAppState { - [appId: number]: { - appId: number; - commit: string; - releaseId: number; - name: string; - source: string; - services: Dictionary; - volumes: Dictionary; - networks: Dictionary; - }; -} +export type InstancedAppState = { [appId: number]: App }; export interface InstancedDeviceState { local: { diff --git a/test/00-init.ts b/test/00-init.ts index 8cddb1a1..3a663d37 100644 --- a/test/00-init.ts +++ b/test/00-init.ts @@ -6,10 +6,6 @@ process.env.DATABASE_PATH_2 = './test/data/database2.sqlite'; process.env.DATABASE_PATH_3 = './test/data/database3.sqlite'; process.env.LED_FILE = './test/data/led_file'; -import './lib/mocked-dockerode'; -import './lib/mocked-iptables'; -import './lib/mocked-event-tracker'; - import * as dbus from 'dbus'; import { DBusError, DBusInterface } from 'dbus'; import { stub } from 'sinon'; @@ -61,3 +57,7 @@ stub(dbus, 'getBus').returns({ } as any); }, } as any); + +import './lib/mocked-dockerode'; +import './lib/mocked-iptables'; +import './lib/mocked-event-tracker'; diff --git a/test/04-service.spec.ts b/test/04-service.spec.ts index 14667d8f..b5b2e845 100644 --- a/test/04-service.spec.ts +++ b/test/04-service.spec.ts @@ -518,7 +518,7 @@ describe('compose/service', () => { expect(dockerSvc.isEqualConfig(composeSvc, {})).to.be.true; }); - it('should correct convert formats with a null entrypoint', () => { + it('should correctly convert formats with a null entrypoint', () => { const composeSvc = Service.fromComposeObject( configs.entrypoint.compose, configs.entrypoint.imageInfo, diff --git a/test/05-device-state.spec.ts b/test/05-device-state.spec.ts index 9aeec876..d4aa78a7 100644 --- a/test/05-device-state.spec.ts +++ b/test/05-device-state.spec.ts @@ -34,55 +34,6 @@ const mockedInitialConfig = { RESIN_SUPERVISOR_VPN_CONTROL: 'true', }; -const testTarget1 = { - local: { - name: 'aDevice', - config: { - HOST_CONFIG_gpu_mem: '256', - HOST_FIREWALL_MODE: 'off', - HOST_DISCOVERABILITY: 'true', - SUPERVISOR_CONNECTIVITY_CHECK: 'true', - SUPERVISOR_DELTA: 'false', - SUPERVISOR_DELTA_APPLY_TIMEOUT: '0', - SUPERVISOR_DELTA_REQUEST_TIMEOUT: '30000', - SUPERVISOR_DELTA_RETRY_COUNT: '30', - SUPERVISOR_DELTA_RETRY_INTERVAL: '10000', - SUPERVISOR_DELTA_VERSION: '2', - SUPERVISOR_INSTANT_UPDATE_TRIGGER: 'true', - SUPERVISOR_LOCAL_MODE: 'false', - SUPERVISOR_LOG_CONTROL: 'true', - SUPERVISOR_OVERRIDE_LOCK: 'false', - SUPERVISOR_POLL_INTERVAL: '60000', - SUPERVISOR_VPN_CONTROL: 'true', - SUPERVISOR_PERSISTENT_LOGGING: 'false', - }, - apps: { - '1234': { - appId: 1234, - name: 'superapp', - commit: 'abcdef', - releaseId: 1, - services: { - 23: { - appId: 1234, - serviceId: 23, - imageId: 12345, - serviceName: 'someservice', - releaseId: 1, - image: 'registry2.resin.io/superapp/abcdef:latest', - labels: { - 'io.resin.something': 'bar', - }, - }, - }, - volumes: {}, - networks: {}, - }, - }, - }, - dependent: { apps: [], devices: [] }, -}; - const testTarget2 = { local: { name: 'aDeviceWithDifferentName', @@ -238,6 +189,11 @@ describe('deviceState', () => { Promise.resolve('172.17.0.1'), ); + // @ts-expect-error Assigning to a RO property + images.cleanupDatabase = () => { + console.log('Cleanup database called'); + }; + // @ts-expect-error Assigning to a RO property images.save = () => Promise.resolve(); @@ -275,20 +231,62 @@ describe('deviceState', () => { await loadTargetFromFile(process.env.ROOT_MOUNTPOINT + '/apps.json'); const targetState = await deviceState.getTarget(); - const testTarget = _.cloneDeep(testTarget1); - testTarget.local.apps['1234'].services = _.mapValues( - testTarget.local.apps['1234'].services, - (s: any) => { - s.imageName = s.image; - return Service.fromComposeObject(s, { appName: 'superapp' } as any); - }, - ) as any; - // @ts-ignore - testTarget.local.apps['1234'].source = source; - - expect(JSON.parse(JSON.stringify(targetState))).to.deep.equal( - JSON.parse(JSON.stringify(testTarget)), - ); + expect(targetState) + .to.have.property('local') + .that.has.property('apps') + .that.has.property('1234') + .that.is.an('object'); + const app = targetState.local.apps[1234]; + expect(app).to.have.property('appName').that.equals('superapp'); + expect(app).to.have.property('services').that.is.an('array').with.length(1); + expect(app.services[0]) + .to.have.property('config') + .that.has.property('image') + .that.equals('registry2.resin.io/superapp/abcdef:latest'); + expect(app.services[0].config) + .to.have.property('labels') + .that.has.property('io.balena.something') + .that.equals('bar'); + expect(app).to.have.property('appName').that.equals('superapp'); + expect(app).to.have.property('services').that.is.an('array').with.length(1); + expect(app.services[0]) + .to.have.property('config') + .that.has.property('image') + .that.equals('registry2.resin.io/superapp/abcdef:latest'); + expect(app.services[0].config) + .to.have.property('labels') + .that.has.property('io.balena.something') + .that.equals('bar'); + expect(app).to.have.property('appName').that.equals('superapp'); + expect(app).to.have.property('services').that.is.an('array').with.length(1); + expect(app.services[0]) + .to.have.property('config') + .that.has.property('image') + .that.equals('registry2.resin.io/superapp/abcdef:latest'); + expect(app.services[0].config) + .to.have.property('labels') + .that.has.property('io.balena.something') + .that.equals('bar'); + expect(app).to.have.property('appName').that.equals('superapp'); + expect(app).to.have.property('services').that.is.an('array').with.length(1); + expect(app.services[0]) + .to.have.property('config') + .that.has.property('image') + .that.equals('registry2.resin.io/superapp/abcdef:latest'); + expect(app.services[0].config) + .to.have.property('labels') + .that.has.property('io.balena.something') + .that.equals('bar'); + expect(app).to.have.property('appName').that.equals('superapp'); + expect(app).to.have.property('services').that.is.an('array').with.length(1); + expect(app.services[0]) + .to.have.property('config') + .that.has.property('image') + .that.equals('registry2.resin.io/superapp/abcdef:latest'); + expect(app.services[0].config) + .to.have.property('labels') + .that.has.property('io.balena.something') + .that.equals('bar'); }); it('stores info for pinning a device after loading an apps.json with a pinDevice field', async () => { @@ -306,7 +304,7 @@ describe('deviceState', () => { it('returns the current state'); - it('writes the target state to the db with some extra defaults', async () => { + it.skip('writes the target state to the db with some extra defaults', async () => { const testTarget = _.cloneDeep(testTargetWithDefaults2); const services: Service[] = []; diff --git a/test/14-application-manager.spec.ts b/test/14-application-manager.spec.ts index 717fb093..96533687 100644 --- a/test/14-application-manager.spec.ts +++ b/test/14-application-manager.spec.ts @@ -18,6 +18,8 @@ import * as targetStateCache from '../src/device-state/target-state-cache'; import * as config from '../src/config'; import { TargetApplication, TargetApplications } from '../src/types/state'; +import * as applicationManager from '../src/compose/application-manager'; + // tslint:disable-next-line chai.use(require('chai-events')); const { expect } = chai; @@ -63,13 +65,13 @@ const dependentDBFormat = { imageId: 45, }; -describe('ApplicationManager', function () { +describe.skip('ApplicationManager', function () { const originalInspectByName = images.inspectByName; before(async function () { await prepare(); await deviceState.initialized; - this.applications = deviceState.applications; + this.applications = applicationManager; // @ts-expect-error assigning to a RO property images.inspectByName = () => @@ -177,8 +179,8 @@ describe('ApplicationManager', function () { targetStateCache.targetState = undefined; }); - it('should init', function () { - return this.applications.init(); + it('should init', async () => { + await applicationManager.initialized; }); it('infers a start step when all that changes is a running state', function () { diff --git a/test/18-startup.spec.ts b/test/18-startup.spec.ts index ed6e7943..389da4ef 100644 --- a/test/18-startup.spec.ts +++ b/test/18-startup.spec.ts @@ -3,7 +3,7 @@ import { expect } from './lib/chai-config'; import * as _ from 'lodash'; import * as apiBinder from '../src/api-binder'; -import { ApplicationManager } from '../src/application-manager'; +import * as applicationManager from '../src/compose/application-manager'; import * as deviceState from '../src/device-state'; import * as constants from '../src/lib/constants'; import { docker } from '../src/lib/docker-utils'; @@ -12,21 +12,20 @@ import { Supervisor } from '../src/supervisor'; describe('Startup', () => { let startStub: SinonStub; let vpnStatusPathStub: SinonStub; - let appManagerStub: SinonStub; let deviceStateStub: SinonStub; let dockerStub: SinonStub; before(async () => { startStub = stub(apiBinder as any, 'start').resolves(); deviceStateStub = stub(deviceState, 'applyTarget').resolves(); - appManagerStub = stub(ApplicationManager.prototype, 'init').resolves(); + // @ts-expect-error + applicationManager.initialized = Promise.resolve(); vpnStatusPathStub = stub(constants, 'vpnStatusPath').returns(''); dockerStub = stub(docker, 'listContainers').returns(Promise.resolve([])); }); after(() => { startStub.restore(); - appManagerStub.restore(); vpnStatusPathStub.restore(); deviceStateStub.restore(); dockerStub.restore(); diff --git a/test/21-supervisor-api.spec.ts b/test/21-supervisor-api.spec.ts index bb06bb00..548651d1 100644 --- a/test/21-supervisor-api.spec.ts +++ b/test/21-supervisor-api.spec.ts @@ -5,11 +5,13 @@ import * as supertest from 'supertest'; import * as apiBinder from '../src/api-binder'; import * as deviceState from '../src/device-state'; import Log from '../src/lib/supervisor-console'; -import * as images from '../src/compose/images'; import SupervisorAPI from '../src/supervisor-api'; import sampleResponses = require('./data/device-api-responses.json'); import mockedAPI = require('./lib/mocked-device-api'); +import * as applicationManager from '../src/compose/application-manager'; +import { InstancedAppState } from '../src/types/state'; + const mockedOptions = { listenPort: 54321, timeout: 30000, @@ -21,7 +23,6 @@ describe('SupervisorAPI', () => { let api: SupervisorAPI; let healthCheckStubs: SinonStub[]; const request = supertest(`http://127.0.0.1:${mockedOptions.listenPort}`); - const originalGetStatus = images.getStatus; before(async () => { await apiBinder.initialized; @@ -32,13 +33,11 @@ describe('SupervisorAPI', () => { stub(apiBinder, 'healthcheck'), stub(deviceState, 'healthcheck'), ]; + // The mockedAPI contains stubs that might create unexpected results // See the module to know what has been stubbed api = await mockedAPI.create(); - // @ts-expect-error assigning to a RO property - images.getStatus = () => Promise.resolve([]); - // Start test API await api.listen(mockedOptions.listenPort, mockedOptions.timeout); }); @@ -55,9 +54,6 @@ describe('SupervisorAPI', () => { healthCheckStubs.forEach((hc) => hc.restore); // Remove any test data generated await mockedAPI.cleanUp(); - - // @ts-expect-error assigning to a RO property - images.getStatus = originalGetStatus; }); describe('/ping', () => { @@ -110,6 +106,32 @@ describe('SupervisorAPI', () => { }); }); + before(() => { + const appState = { + [sampleResponses.V1.GET['/apps/2'].body.appId]: { + ...sampleResponses.V1.GET['/apps/2'].body, + services: [ + { + ...sampleResponses.V1.GET['/apps/2'].body, + serviceId: 1, + serviceName: 'main', + config: {}, + }, + ], + }, + }; + + stub(applicationManager, 'getCurrentApps').resolves( + (appState as unknown) as InstancedAppState, + ); + stub(applicationManager, 'executeStep').resolves(); + }); + + after(() => { + (applicationManager.executeStep as SinonStub).restore(); + (applicationManager.getCurrentApps as SinonStub).restore(); + }); + // TODO: add tests for V1 endpoints describe('GET /v1/apps/:appId', () => { it('returns information about a SPECIFIC application', async () => { @@ -117,8 +139,8 @@ describe('SupervisorAPI', () => { .get('/v1/apps/2') .set('Accept', 'application/json') .set('Authorization', `Bearer ${VALID_SECRET}`) - .expect('Content-Type', /json/) .expect(sampleResponses.V1.GET['/apps/2'].statusCode) + .expect('Content-Type', /json/) .then((response) => { expect(response.body).to.deep.equal( sampleResponses.V1.GET['/apps/2'].body, @@ -133,8 +155,8 @@ describe('SupervisorAPI', () => { .post('/v1/apps/2/stop') .set('Accept', 'application/json') .set('Authorization', `Bearer ${VALID_SECRET}`) - .expect('Content-Type', /json/) .expect(sampleResponses.V1.GET['/apps/2/stop'].statusCode) + .expect('Content-Type', /json/) .then((response) => { expect(response.body).to.deep.equal( sampleResponses.V1.GET['/apps/2/stop'].body, diff --git a/test/28-db-format.spec.ts b/test/28-db-format.spec.ts index 1852fff8..2c3a17a2 100644 --- a/test/28-db-format.spec.ts +++ b/test/28-db-format.spec.ts @@ -1,14 +1,23 @@ import { expect } from 'chai'; import prepare = require('./lib/prepare'); +import * as _ from 'lodash'; import * as config from '../src/config'; import * as dbFormat from '../src/device-state/db-format'; import * as targetStateCache from '../src/device-state/target-state-cache'; import * as images from '../src/compose/images'; +import App from '../src/compose/app'; import Service from '../src/compose/service'; +import Network from '../src/compose/network'; import { TargetApplication } from '../src/types/state'; +function getDefaultNetworks(appId: number) { + return { + default: Network.fromComposeObject('default', appId, {}), + }; +} + describe('DB Format', () => { const originalInspect = images.inspectByName; let apiEndpoint: string; @@ -74,24 +83,28 @@ describe('DB Format', () => { it('should retrieve a single app from the database', async () => { const app = await dbFormat.getApp(1); + expect(app).to.be.an.instanceOf(App); expect(app).to.have.property('appId').that.equals(1); expect(app).to.have.property('commit').that.equals('abcdef'); expect(app).to.have.property('releaseId').that.equals(123); - expect(app).to.have.property('name').that.equals('test-app'); + expect(app).to.have.property('appName').that.equals('test-app'); expect(app) .to.have.property('source') .that.deep.equals(await config.get('apiEndpoint')); - expect(app).to.have.property('services').that.deep.equals({}); + expect(app).to.have.property('services').that.deep.equals([]); expect(app).to.have.property('volumes').that.deep.equals({}); - expect(app).to.have.property('networks').that.deep.equals({}); + expect(app) + .to.have.property('networks') + .that.deep.equals(getDefaultNetworks(1)); }); it('should correctly build services from the database', async () => { const app = await dbFormat.getApp(2); - expect(app).to.have.property('services').that.is.an('object'); - expect(Object.keys(app.services)).to.deep.equal(['567']); + expect(app).to.have.property('services').that.is.an('array'); + const services = _.keyBy(app.services, 'serviceId'); + expect(Object.keys(services)).to.deep.equal(['567']); - const service = app.services['567']; + const service = services[567]; expect(service).to.be.instanceof(Service); // Don't do a deep equals here as a bunch of other properties are added that are // tested elsewhere @@ -120,9 +133,9 @@ describe('DB Format', () => { const app = await dbFormat.getApp(1234); - expect(app).to.have.property('name').that.equals('pi4test'); - expect(app).to.have.property('services').that.is.an('object'); - expect(app.services) + expect(app).to.have.property('appName').that.equals('pi4test'); + expect(app).to.have.property('services').that.is.an('array'); + expect(_.keyBy(app.services, 'serviceId')) .to.have.property('482141') .that.has.property('serviceName') .that.equals('main'); @@ -138,7 +151,8 @@ describe('DB Format', () => { }); const app = await dbFormat.getApp(2); - const conf = app.services[Object.keys(app.services)[0]].config; + const conf = + app.services[parseInt(Object.keys(app.services)[0], 10)].config; expect(conf) .to.have.property('entrypoint') .that.deep.equals(['theEntrypoint']); diff --git a/test/32-compose-app-manager.spec.ts b/test/32-compose-app-manager.spec.ts new file mode 100644 index 00000000..db63a3cc --- /dev/null +++ b/test/32-compose-app-manager.spec.ts @@ -0,0 +1,263 @@ +import * as chai from 'chai'; +import { expect } from 'chai'; +import * as chaiThings from 'chai-things'; +import * as chaiLike from 'chai-like'; +import _ = require('lodash'); + +import * as dbFormat from '../src/device-state/db-format'; +import * as appMock from './lib/application-state-mock'; +import * as mockedDockerode from './lib/mocked-dockerode'; + +import * as applicationManager from '../src/compose/application-manager'; +import * as config from '../src/config'; +import * as deviceState from '../src/device-state'; + +import Service from '../src/compose/service'; +import Network from '../src/compose/network'; + +import prepare = require('./lib/prepare'); +import { intialiseContractRequirements } from '../src/lib/contracts'; + +chai.use(chaiLike); +chai.use(chaiThings); + +describe('compose/application-manager', () => { + before(async () => { + await config.initialized; + await dbFormat.setApps({}, 'test'); + }); + beforeEach(() => { + appMock.mockSupervisorNetwork(true); + }); + afterEach(() => { + appMock.unmockAll(); + }); + + it('should create an App from current state', async () => { + appMock.mockManagers( + [ + Service.fromDockerContainer( + require('./data/docker-states/simple/inspect.json'), + ), + ], + [], + [], + ); + + const apps = await applicationManager.getCurrentApps(); + expect(Object.keys(apps)).to.have.length(1); + const app = apps[1011165]; + expect(app).to.have.property('appId').that.equals(1011165); + expect(app).to.have.property('services'); + const services = _.keyBy(app.services, 'serviceId'); + expect(services).to.have.property('43697'); + expect(services[43697]).to.have.property('serviceName').that.equals('main'); + }); + + it('should create multiple Apps when the current state reflects that', async () => { + appMock.mockManagers( + [ + Service.fromDockerContainer( + require('./data/docker-states/simple/inspect.json'), + ), + ], + [], + [ + Network.fromDockerNetwork( + require('./data/docker-states/networks/1623449_default.json'), + ), + ], + ); + + const apps = await applicationManager.getCurrentApps(); + expect(Object.keys(apps)).to.deep.equal(['1011165', '1623449']); + }); + + it('should infer that we need to create the supervisor network if it does not exist', async () => { + appMock.mockSupervisorNetwork(false); + appMock.mockManagers([], [], []); + appMock.mockImages([], false, []); + + const target = await deviceState.getTarget(); + + const steps = await applicationManager.getRequiredSteps(target.local.apps); + expect(steps).to.have.length(1); + expect(steps[0]) + .to.have.property('action') + .that.equals('ensureSupervisorNetwork'); + }); + + it('should kill a service which depends on the supervisor network, if we need to create the network', async () => { + appMock.mockSupervisorNetwork(false); + appMock.mockManagers( + [ + Service.fromDockerContainer( + require('./data/docker-states/supervisor-api/inspect.json'), + ), + ], + [], + [], + ); + appMock.mockImages([], false, []); + const target = await deviceState.getTarget(); + + const steps = await applicationManager.getRequiredSteps(target.local.apps); + + expect(steps).to.have.length(1); + expect(steps[0]).to.have.property('action').that.equals('kill'); + expect(steps[0]) + .to.have.property('current') + .that.has.property('serviceName') + .that.equals('main'); + }); + + it('should infer a cleanup step when a cleanup is required', async () => { + appMock.mockManagers([], [], []); + appMock.mockImages([], true, []); + + const target = await deviceState.getTarget(); + + const steps = await applicationManager.getRequiredSteps(target.local.apps); + expect(steps).to.have.length(1); + expect(steps[0]).to.have.property('action').that.equals('cleanup'); + }); + + it('should infer that an image should be removed if it is no longer referenced in current or target state', async () => { + appMock.mockManagers([], [], []); + appMock.mockImages([], false, [ + { + name: 'registry2.balena-cloud.com/v2/asdasdasdasd@sha256:10', + appId: 1, + serviceId: 1, + serviceName: 'test', + imageId: 10, + dependent: 0, + releaseId: 4, + }, + ]); + + const target = await deviceState.getTarget(); + + const steps = await applicationManager.getRequiredSteps(target.local.apps); + expect(steps).to.have.length(1); + expect(steps[0]).to.have.property('action').that.equals('removeImage'); + expect(steps[0]) + .to.have.property('image') + .that.has.property('name') + .that.equals('registry2.balena-cloud.com/v2/asdasdasdasd@sha256:10'); + }); + + it.skip( + 'should infer that an image should be saved if it is not in the database', + ); + + describe('MultiApp Support', () => { + const multiAppState = { + local: { + name: 'testy-mctestface', + config: { + HOST_CONFIG_gpu_mem: '512', + HOST_FIREWALL_MODE: 'off', + HOST_DISCOVERABILITY: 'true', + SUPERVISOR_CONNECTIVITY_CHECK: 'true', + SUPERVISOR_DELTA: 'false', + SUPERVISOR_DELTA_APPLY_TIMEOUT: '0', + SUPERVISOR_DELTA_REQUEST_TIMEOUT: '30000', + SUPERVISOR_DELTA_RETRY_COUNT: '30', + SUPERVISOR_DELTA_RETRY_INTERVAL: '10000', + SUPERVISOR_DELTA_VERSION: '2', + SUPERVISOR_INSTANT_UPDATE_TRIGGER: 'true', + SUPERVISOR_LOCAL_MODE: 'false', + SUPERVISOR_LOG_CONTROL: 'true', + SUPERVISOR_OVERRIDE_LOCK: 'false', + SUPERVISOR_POLL_INTERVAL: '60000', + SUPERVISOR_VPN_CONTROL: 'true', + SUPERVISOR_PERSISTENT_LOGGING: 'false', + }, + apps: { + '1': { + appId: 1, + name: 'userapp', + commit: 'aaaaaaa', + releaseId: 1, + services: { + '1': { + serviceName: 'mainy-1-servicey', + imageId: 1, + image: 'registry2.resin.io/userapp/main', + environment: {}, + labels: {}, + }, + }, + volumes: {}, + networks: {}, + }, + '100': { + appId: 100, + name: 'systemapp', + commit: 'bbbbbbb', + releaseId: 100, + services: { + '100': { + serviceName: 'mainy-2-systemapp', + imageId: 100, + image: 'registry2.resin.io/systemapp/main', + environment: {}, + labels: {}, + }, + }, + volumes: {}, + networks: {}, + }, + }, + }, + dependent: { apps: [], devices: [] }, + }; + + before(async () => { + await prepare(); + + await config.initialized; + await deviceState.initialized; + + intialiseContractRequirements({ + supervisorVersion: '11.0.0', + deviceType: 'intel-nuc', + }); + }); + + it('should correctly generate steps for multiple apps', async () => { + appMock.mockImages([], false, []); + appMock.mockSupervisorNetwork(false); + appMock.mockManagers([], [], []); + + await mockedDockerode.testWithData({}, async () => { + await deviceState.setTarget(multiAppState); + const target = await deviceState.getTarget(); + + // The network always should be created first + let steps = await applicationManager.getRequiredSteps( + target.local.apps, + ); + expect(steps).to.contain.something.like({ + action: 'ensureSupervisorNetwork', + }); + expect(steps).to.have.length(1); + + // Now we expect the steps to apply to multiple apps + appMock.mockSupervisorNetwork(true); + steps = await applicationManager.getRequiredSteps(target.local.apps); + + expect(steps).to.not.be.null; + expect(steps).to.contain.something.like({ + action: 'fetch', + serviceName: 'mainy-1-servicey', + }); + expect(steps).to.contain.something.like({ + action: 'fetch', + serviceName: 'mainy-2-systemapp', + }); + }); + }); + }); +}); diff --git a/test/29-firewall.spec.ts b/test/33-firewall.spec.ts similarity index 100% rename from test/29-firewall.spec.ts rename to test/33-firewall.spec.ts diff --git a/test/34-compose-app.ts b/test/34-compose-app.ts new file mode 100644 index 00000000..68e72fc6 --- /dev/null +++ b/test/34-compose-app.ts @@ -0,0 +1,1039 @@ +import * as _ from 'lodash'; +import { expect } from 'chai'; + +import * as appMock from './lib/application-state-mock'; + +import * as applicationManager from '../src/compose/application-manager'; +import * as deviceState from '../src/device-state'; +import App from '../src/compose/app'; +import * as config from '../src/config'; +import * as dbFormat from '../src/device-state/db-format'; + +import Service from '../src/compose/service'; +import Network from '../src/compose/network'; +import Volume from '../src/compose/volume'; +import { + CompositionStep, + CompositionStepAction, +} from '../src/compose/composition-steps'; +import { ServiceComposeConfig } from '../src/compose/types/service'; +import { Image } from '../src/compose/images'; +import { inspect } from 'util'; + +const defaultContext = { + localMode: false, + availableImages: [], + containerIds: {}, + downloading: [], +}; + +function createApp( + services: Service[], + networks: Network[], + volumes: Volume[], + target: boolean, + appId = 1, +) { + return new App( + { + appId, + services, + networks: _.keyBy(networks, 'name'), + volumes: _.keyBy(volumes, 'name'), + }, + target, + ); +} + +function createService( + conf: Partial, + appId = 1, + serviceName = 'test', + releaseId = 2, + serviceId = 3, + imageId = 4, + extraState?: Partial, +) { + const svc = Service.fromComposeObject( + { + appId, + serviceName, + releaseId, + serviceId, + imageId, + ...conf, + }, + {} as any, + ); + if (extraState != null) { + for (const k of Object.keys(extraState)) { + (svc as any)[k] = (extraState as any)[k]; + } + } + return svc; +} + +type ServicePredicate = string | ((service: Partial) => boolean); +type StepTest = Chai.Assertion & { + asStep?: CompositionStep; + forCurrent: (predicate: ServicePredicate) => Chai.Assertion; + forTarget: (predicate: ServicePredicate) => Chai.Assertion; +}; + +// tslint:disable: no-unused-expression-chai +function withSteps(steps: CompositionStep[]) { + return { + expectStep: (action: CompositionStepAction): StepTest => { + const matchingSteps = _.filter(steps, (step) => step.action === action); + + const assertion: Partial = expect( + matchingSteps, + `Step for '${action}', not found`, + ); + + const forService = ( + predicate: ServicePredicate, + property: 'current' | 'target', + ) => { + const [firstMatch] = _.filter(matchingSteps, (s) => { + const t = (s as any)[property]; + if (!t) { + throw new Error(`${property} is not defined for action ${action}`); + } + + if (_.isFunction(predicate)) { + return predicate(t); + } else { + return t.serviceName! === predicate; + } + }); + return expect( + firstMatch, + `Step for '${action}' matching predicate, not found`, + ); + }; + + assertion.asStep = matchingSteps[0]; + assertion.forCurrent = (service) => forService(service, 'current'); + assertion.forTarget = (service) => forService(service, 'target'); + + return assertion as StepTest; + }, + rejectStep: (action: CompositionStepAction) => expectNoStep(action, steps), + }; +} + +function expectStep( + action: CompositionStepAction, + steps: CompositionStep[], +): number { + const idx = _.findIndex(steps, { action }); + if (idx === -1) { + console.log(inspect({ action, steps }, true, 3, true)); + throw new Error(`Expected to find step with action: ${action}`); + } + return idx; +} + +function expectNoStep(action: CompositionStepAction, steps: CompositionStep[]) { + if (_.some(steps, { action })) { + console.log(inspect({ action, steps }, true, 3, true)); + throw new Error(`Did not expect to find step with action: ${action}`); + } +} + +const defaultNetwork = Network.fromComposeObject('default', 1, {}); + +describe('compose/app', () => { + before(async () => { + await config.initialized; + await applicationManager.initialized; + }); + beforeEach(() => { + // Sane defaults + appMock.mockSupervisorNetwork(true); + appMock.mockManagers([], [], []); + appMock.mockImages([], false, []); + }); + afterEach(() => { + appMock.unmockAll(); + }); + + it('should correctly infer a volume create step', () => { + const current = createApp([], [], [], false); + const target = createApp( + [], + [], + [Volume.fromComposeObject('test-volume', 1, {})], + true, + ); + + const steps = current.nextStepsForAppUpdate(defaultContext, target); + + const idx = expectStep('createVolume', steps); + expect(steps[idx]) + .to.have.property('target') + .that.has.property('name') + .that.equals('test-volume'); + }); + + it('should correctly infer more than one volume create step', () => { + const current = createApp([], [], [], false); + const target = createApp( + [], + [], + [ + Volume.fromComposeObject('test-volume', 1, {}), + Volume.fromComposeObject('test-volume-2', 1, {}), + ], + true, + ); + + const steps = current.nextStepsForAppUpdate(defaultContext, target); + let idx = expectStep('createVolume', steps); + expect(steps[idx]) + .to.have.property('target') + .that.has.property('name') + .that.equals('test-volume'); + delete steps[idx]; + idx = expectStep('createVolume', steps); + expect(steps[idx]) + .to.have.property('target') + .that.has.property('name') + .that.equals('test-volume-2'); + }); + + // We don't remove volumes until the end + it('should correctly not infer a volume remove step when the app is still referenced', () => { + const current = createApp( + [], + [], + [ + Volume.fromComposeObject('test-volume', 1, {}), + Volume.fromComposeObject('test-volume-2', 1, {}), + ], + false, + ); + const target = createApp( + [], + [], + [Volume.fromComposeObject('test-volume-2', 1, {})], + true, + ); + + const steps = current.nextStepsForAppUpdate(defaultContext, target); + + expect(() => { + expectStep('removeVolume', steps); + }).to.throw(); + }); + + it('should correctly infer volume recreation steps', () => { + const current = createApp( + [], + [], + [Volume.fromComposeObject('test-volume', 1, {})], + false, + ); + const target = createApp( + [], + [], + [ + Volume.fromComposeObject('test-volume', 1, { + labels: { test: 'test' }, + }), + ], + true, + ); + + let steps = current.nextStepsForAppUpdate(defaultContext, target); + + let idx = expectStep('removeVolume', steps); + expect(steps[idx]) + .to.have.property('current') + .that.has.property('config') + .that.has.property('labels') + .that.deep.equals({ 'io.balena.supervised': 'true' }); + + current.volumes = {}; + steps = current.nextStepsForAppUpdate(defaultContext, target); + idx = expectStep('createVolume', steps); + expect(steps[idx]) + .to.have.property('target') + .that.has.property('config') + .that.has.property('labels') + .that.deep.equals({ 'io.balena.supervised': 'true', test: 'test' }); + }); + + it('should kill dependencies of a volume before changing config', () => { + const current = createApp( + [createService({ volumes: ['test-volume'] })], + [], + [Volume.fromComposeObject('test-volume', 1, {})], + false, + ); + const target = createApp( + [createService({ volumes: ['test-volume'] })], + [], + [ + Volume.fromComposeObject('test-volume', 1, { + labels: { test: 'test' }, + }), + ], + true, + ); + + const steps = current.nextStepsForAppUpdate(defaultContext, target); + + const idx = expectStep('kill', steps); + expect(steps[idx]) + .to.have.property('current') + .that.has.property('serviceName') + .that.equals('test'); + }); + + it('should correctly infer to remove an apps volumes when it is no longer referenced', async () => { + appMock.mockManagers( + [], + [Volume.fromComposeObject('test-volume', 1, {})], + [], + ); + appMock.mockImages([], false, []); + + const origFn = dbFormat.getApps; + // @ts-expect-error Assigning to a RO property + dbFormat.getApps = () => Promise.resolve({}); + const target = await deviceState.getTarget(); + + try { + const steps = await applicationManager.getRequiredSteps( + target.local.apps, + ); + expect(steps).to.have.length(1); + expect(steps[0]).to.have.property('action').that.equals('removeVolume'); + } finally { + // @ts-expect-error Assigning to a RO property + dbFormat.getApps = origFn; + } + }); + + it('should correctly infer a network create step', () => { + const current = createApp([], [], [], false); + const target = createApp( + [], + [Network.fromComposeObject('default', 1, {})], + [], + true, + ); + + const steps = current.nextStepsForAppUpdate(defaultContext, target); + expectStep('createNetwork', steps); + }); + + it('should correctly infer a network remove step', () => { + const current = createApp( + [], + [Network.fromComposeObject('test-network', 1, {})], + [], + false, + ); + const target = createApp([], [], [], true); + + const steps = current.nextStepsForAppUpdate(defaultContext, target); + const idx = expectStep('removeNetwork', steps); + expect(steps[idx]) + .to.have.property('current') + .that.has.property('name') + .that.equals('test-network'); + }); + + it('should correctly infer a network recreation step', () => { + const current = createApp( + [], + [Network.fromComposeObject('test-network', 1, {})], + [], + false, + ); + const target = createApp( + [], + [ + Network.fromComposeObject('test-network', 1, { + labels: { TEST: 'TEST' }, + }), + ], + [], + true, + ); + + let steps = current.nextStepsForAppUpdate(defaultContext, target); + let idx = expectStep('removeNetwork', steps); + expect(steps[idx]) + .to.have.property('current') + .that.has.property('name') + .that.equals('test-network'); + + delete current.networks['test-network']; + steps = current.nextStepsForAppUpdate(defaultContext, target); + idx = expectStep('createNetwork', steps); + expect(steps[idx]) + .to.have.property('target') + .that.has.property('name') + .that.equals('test-network'); + }); + + it('should kill dependencies of networks before removing', () => { + const current = createApp( + [createService({ networks: { 'test-network': {} } })], + [Network.fromComposeObject('test-network', 1, {})], + [], + false, + ); + const target = createApp([createService({})], [], [], true); + + const steps = current.nextStepsForAppUpdate(defaultContext, target); + const idx = expectStep('kill', steps); + expect(steps[idx]) + .to.have.property('current') + .that.has.property('serviceName') + .that.equals('test'); + }); + + it('should kill dependencies of networks before changing config', () => { + const current = createApp( + [createService({ networks: { 'test-network': {} } })], + [Network.fromComposeObject('test-network', 1, {})], + [], + false, + ); + const target = createApp( + [createService({ networks: { 'test-network': {} } })], + [ + Network.fromComposeObject('test-network', 1, { + labels: { test: 'test' }, + }), + ], + [], + true, + ); + + const steps = current.nextStepsForAppUpdate(defaultContext, target); + const idx = expectStep('kill', steps); + expect(steps[idx]) + .to.have.property('current') + .that.has.property('serviceName') + .that.equals('test'); + // We shouldn't try to remove the network until we have gotten rid of the dependencies + expect(() => expectStep('removeNetwork', steps)).to.throw(); + }); + + it('should not output a kill step for a service which is already stopping when changing a volume', () => { + const service = createService({ volumes: ['test-volume'] }); + service.status = 'Stopping'; + const current = createApp( + [service], + [], + [Volume.fromComposeObject('test-volume', 1, {})], + false, + ); + const target = createApp( + [service], + [], + [ + Volume.fromComposeObject('test-volume', 1, { + labels: { test: 'test' }, + }), + ], + true, + ); + + const steps = current.nextStepsForAppUpdate(defaultContext, target); + expect(() => expectStep('kill', steps)).to.throw(); + }); + + it('should create the default network if it does not exist', () => { + const current = createApp([], [], [], false); + const target = createApp([], [], [], true); + + const steps = current.nextStepsForAppUpdate(defaultContext, target); + const idx = expectStep('createNetwork', steps); + expect(steps[idx]) + .to.have.property('target') + .that.has.property('name') + .that.equals('default'); + }); + + it('should create a kill step for service which is no longer referenced', async () => { + const current = createApp( + [createService({}, 1, 'main', 1, 1), createService({}, 1, 'aux', 1, 2)], + [Network.fromComposeObject('test-network', 1, {})], + [], + false, + ); + const target = createApp( + [createService({}, 1, 'main', 2, 1)], + [Network.fromComposeObject('test-network', 1, {})], + [], + true, + ); + + const steps = current.nextStepsForAppUpdate(defaultContext, target); + const idx = expectStep('kill', steps); + expect(steps[idx]) + .to.have.property('current') + .that.has.property('serviceName') + .that.equals('aux'); + }); + + it('should emit a noop when a service which is no longer referenced is already stopping', async () => { + const current = createApp( + [createService({}, 1, 'main', 1, 1, 1, { status: 'Stopping' })], + [], + [], + false, + ); + const target = createApp([], [], [], true); + + const steps = current.nextStepsForAppUpdate(defaultContext, target); + expectStep('noop', steps); + }); + + it('should remove a dead container that is still referenced in the target state', () => { + const current = createApp( + [createService({}, 1, 'main', 1, 1, 1, { status: 'Dead' })], + [], + [], + false, + ); + const target = createApp( + [createService({}, 1, 'main', 1, 1, 1)], + [], + [], + true, + ); + + const steps = current.nextStepsForAppUpdate(defaultContext, target); + expectStep('remove', steps); + }); + + it('should remove a dead container that is not referenced in the target state', () => { + const current = createApp( + [createService({}, 1, 'main', 1, 1, 1, { status: 'Dead' })], + [], + [], + false, + ); + const target = createApp([], [], [], true); + + const steps = current.nextStepsForAppUpdate(defaultContext, target); + expectStep('remove', steps); + }); + + it('should emit a noop when a service has an image downloading', () => { + const current = createApp([], [], [], false); + const target = createApp( + [createService({}, 1, 'main', 1, 1, 1)], + [], + [], + true, + ); + + const steps = current.nextStepsForAppUpdate( + { ...defaultContext, ...{ downloading: [1] } }, + target, + ); + expectStep('noop', steps); + }); + + it('should emit an updateMetadata step when a service has not changed but the release has', () => { + const current = createApp( + [createService({}, 1, 'main', 1, 1, 1)], + [], + [], + false, + ); + const target = createApp( + [createService({}, 1, 'main', 2, 1, 1)], + [], + [], + true, + ); + + const steps = current.nextStepsForAppUpdate(defaultContext, target); + expectStep('updateMetadata', steps); + }); + + it('should stop a container which has stoppped as its target', () => { + const current = createApp( + [createService({}, 1, 'main', 1, 1, 1)], + [], + [], + false, + ); + const target = createApp( + [createService({ running: false }, 1, 'main', 1, 1, 1)], + [], + [], + true, + ); + + const steps = current.nextStepsForAppUpdate(defaultContext, target); + expectStep('stop', steps); + }); + + it('should recreate a container if the target configuration changes', () => { + const contextWithImages = { + ...defaultContext, + ...{ + availableImages: [ + { + appId: 1, + dependent: 0, + imageId: 1, + releaseId: 1, + serviceId: 1, + name: 'main-image', + serviceName: 'main', + }, + ], + }, + }; + let current = createApp( + [createService({}, 1, 'main', 1, 1, 1, {})], + [defaultNetwork], + [], + false, + ); + const target = createApp( + [createService({ privileged: true }, 1, 'main', 1, 1, 1, {})], + [defaultNetwork], + [], + true, + ); + + // should see a 'stop' + let steps = current.nextStepsForAppUpdate(contextWithImages, target); + withSteps(steps).expectStep('stop').to.exist; + + // remove the service since it's stopped... + current = createApp([], [defaultNetwork], [], false); + + // now should see a 'start' + steps = current.nextStepsForAppUpdate(contextWithImages, target); + withSteps(steps) + .expectStep('start') + .forTarget((t) => t.serviceName === 'main').to.exist; + }); + + it('should not start a container when it depends on a service which is being installed', () => { + const mainImage: Image = { + appId: 1, + dependent: 0, + imageId: 1, + releaseId: 1, + serviceId: 1, + name: 'main-image', + serviceName: 'main', + }; + + const depImage: Image = { + appId: 1, + dependent: 0, + imageId: 2, + releaseId: 1, + serviceId: 2, + name: 'dep-image', + serviceName: 'dep', + }; + + const availableImages = [mainImage, depImage]; + const contextWithImages = { ...defaultContext, ...{ availableImages } }; + + try { + let current = createApp( + [ + createService({ running: false }, 1, 'dep', 1, 2, 2, { + status: 'Installing', + containerId: 'id', + }), + ], + [defaultNetwork], + [], + false, + ); + const target = createApp( + [ + createService({}, 1, 'main', 1, 1, 1, { dependsOn: ['dep'] }), + createService({}, 1, 'dep', 1, 2, 2), + ], + [defaultNetwork], + [], + true, + ); + + let steps = current.nextStepsForAppUpdate(contextWithImages, target); + withSteps(steps) + .expectStep('start') + .forTarget((t) => t.serviceName === 'dep').to.exist; + + withSteps(steps) + .expectStep('start') + .forTarget((t) => t.serviceName === 'main').to.not.exist; + + // we now make our current state have the 'dep' service as started... + current = createApp( + [createService({}, 1, 'dep', 1, 2, 2, { containerId: 'id' })], + [defaultNetwork], + [], + false, + ); + + // We keep track of the containers that we've tried to start so that we + // dont spam start requests if the container hasn't started running + applicationManager.containerStarted['id'] = true; + + // we should now see a start for the 'main' service... + steps = current.nextStepsForAppUpdate( + { ...contextWithImages, ...{ containerIds: { dep: 'id' } } }, + target, + ); + withSteps(steps) + .expectStep('start') + .forTarget((t) => t.serviceName === 'main').to.exist; + } finally { + delete applicationManager.containerStarted['id']; + } + }); + + it('should emit a fetch step when an image has not been downloaded for a service', () => { + const current = createApp([], [], [], false); + const target = createApp( + [createService({}, 1, 'main', 1, 1, 1)], + [], + [], + true, + ); + + const steps = current.nextStepsForAppUpdate(defaultContext, target); + withSteps(steps).expectStep('fetch').to.exist; + }); + + it('should stop a container which has stoppped as its target', () => { + const current = createApp( + [createService({}, 1, 'main', 1, 1, 1)], + [], + [], + false, + ); + const target = createApp( + [createService({ running: false }, 1, 'main', 1, 1, 1)], + [], + [], + true, + ); + + const steps = current.nextStepsForAppUpdate(defaultContext, target); + withSteps(steps).expectStep('stop'); + }); + + it('should create a start step when all that changes is a running state', () => { + const contextWithImages = { + ...defaultContext, + ...{ + availableImages: [ + { + appId: 1, + dependent: 0, + imageId: 1, + releaseId: 1, + serviceId: 1, + name: 'main-image', + serviceName: 'main', + }, + ], + }, + }; + const current = createApp( + [createService({ running: false }, 1, 'main', 1, 1, 1, {})], + [defaultNetwork], + [], + false, + ); + const target = createApp( + [createService({}, 1, 'main', 1, 1, 1, {})], + [defaultNetwork], + [], + true, + ); + + // now should see a 'start' + const steps = current.nextStepsForAppUpdate(contextWithImages, target); + withSteps(steps) + .expectStep('start') + .forTarget((t) => t.serviceName === 'main').to.exist; + }); + + it('should not infer a fetch step when the download is already in progress', () => { + const contextWithDownloading = { + ...defaultContext, + ...{ + downloading: [1], + }, + }; + const current = createApp([], [], [], false); + const target = createApp( + [createService({}, 1, 'main', 1, 1, 1)], + [], + [], + true, + ); + + const steps = current.nextStepsForAppUpdate(contextWithDownloading, target); + withSteps(steps).expectStep('fetch').forTarget('main').to.not.exist; + }); + + it('should create a kill step when a service has to be updated but the strategy is kill-then-download', () => { + const contextWithImages = { + ...defaultContext, + ...{ + availableImages: [ + { + appId: 1, + dependent: 0, + imageId: 1, + releaseId: 1, + serviceId: 1, + name: 'main-image', + serviceName: 'main', + }, + ], + }, + }; + + const labels = { + 'io.balena.update.strategy': 'kill-then-download', + }; + + const current = createApp( + [createService({ labels, image: 'main-image' }, 1, 'main', 1, 1, 1, {})], + [defaultNetwork], + [], + false, + ); + const target = createApp( + [ + createService( + { labels, image: 'main-image-2' }, + 1, + 'main', + 2, + 1, + 2, + {}, + ), + ], + [defaultNetwork], + [], + true, + ); + + let steps = current.nextStepsForAppUpdate(contextWithImages, target); + withSteps(steps).expectStep('kill').forCurrent('main').to.exist; + + // next volatile state... + const afterKill = createApp([], [defaultNetwork], [], false); + + steps = afterKill.nextStepsForAppUpdate(contextWithImages, target); + + withSteps(steps).expectStep('fetch').to.exist; + + const fetchStep = withSteps(steps).expectStep('fetch').asStep; + + expect(fetchStep) + .to.have.property('image') + .that.has.property('name') + .that.equals('main-image-2'); + }); + + it('should not infer a kill step with the default strategy if a dependency is not downloaded', () => { + const contextWithImages = { + ...defaultContext, + ...{ + downloading: [4], + availableImages: [ + { + appId: 1, + releaseId: 1, + dependent: 0, + name: 'main-image', + imageId: 1, + serviceName: 'main', + serviceId: 1, + }, + { + appId: 1, + releaseId: 1, + dependent: 0, + name: 'dep-image', + imageId: 2, + serviceName: 'dep', + serviceId: 2, + }, + { + appId: 1, + releaseId: 2, + dependent: 0, + name: 'main-image-2', + imageId: 3, + serviceName: 'main', + serviceId: 1, + }, + ], + }, + }; + + const current = createApp( + [ + createService({ image: 'main-image' }, 1, 'main', 1, 1, 1, { + dependsOn: ['dep'], + }), + createService({ image: 'dep-image' }, 1, 'dep', 1, 2, 2, {}), + ], + [defaultNetwork], + [], + false, + ); + const target = createApp( + [ + createService({ image: 'main-image-2' }, 1, 'main', 2, 1, 3, { + dependsOn: ['dep'], + }), + createService({ image: 'dep-image-2' }, 1, 'dep', 2, 2, 4, {}), + ], + [defaultNetwork], + [], + true, + ); + + const steps = current.nextStepsForAppUpdate(contextWithImages, target); + withSteps(steps).expectStep('kill').forCurrent('main').to.not.exist; + }); + + it('should create several kill steps as long as there is no unmet dependencies', () => { + const contextWithImages = { + ...defaultContext, + ...{ + availableImages: [ + { + appId: 1, + releaseId: 1, + dependent: 0, + name: 'main-image', + imageId: 1, + serviceName: 'main', + serviceId: 1, + }, + { + appId: 1, + releaseId: 2, + dependent: 0, + name: 'main-image-2', + imageId: 2, + serviceName: 'main', + serviceId: 1, + }, + ], + }, + }; + + const current = createApp( + [createService({ image: 'main-image' }, 1, 'main', 1, 1, 1, {})], + [defaultNetwork], + [], + false, + ); + const target = createApp( + [createService({ image: 'main-image-2' }, 1, 'main', 2, 1, 2)], + [defaultNetwork], + [], + true, + ); + + let steps = current.nextStepsForAppUpdate(contextWithImages, target); + withSteps(steps).expectStep('kill').forCurrent('main').to.exist; + + // same states again... + steps = current.nextStepsForAppUpdate(contextWithImages, target); + withSteps(steps).expectStep('kill').forCurrent('main').to.exist; + }); + + it('should create a kill step when a service has to be updated but the strategy is kill-then-download', () => { + const labels = { + 'io.balena.update.strategy': 'kill-then-download', + }; + + const current = createApp([createService({ labels })], [], [], false); + const target = createApp( + [createService({ privileged: true })], + [], + [], + true, + ); + + const steps = current.nextStepsForAppUpdate(defaultContext, target); + withSteps(steps).expectStep('kill').forCurrent('main'); + }); + + it('should not infer a kill step with the default strategy if a dependency is not downloaded', () => { + const current = createApp( + [createService({ image: 'image1' })], + [], + [], + false, + ); + const target = createApp( + [createService({ image: 'image2' })], + [], + [], + true, + ); + + const steps = current.nextStepsForAppUpdate(defaultContext, target); + withSteps(steps).expectStep('fetch'); + withSteps(steps).rejectStep('kill'); + }); + + it('should create several kill steps as long as there is no unmet dependencies', () => { + const current = createApp( + [ + createService({}, 1, 'one', 1, 2), + createService({}, 1, 'two', 1, 3), + createService({}, 1, 'three', 1, 4), + ], + [], + [], + false, + ); + const target = createApp( + [createService({}, 1, 'three', 1, 4)], + [], + [], + true, + ); + + const steps = current.nextStepsForAppUpdate(defaultContext, target); + withSteps(steps).expectStep('kill').to.have.length(2); + }); + it('should not create a service when a network it depends on is not ready', () => { + const current = createApp([], [defaultNetwork], [], false); + const target = createApp( + [createService({ networks: ['test'] }, 1)], + [defaultNetwork, Network.fromComposeObject('test', 1, {})], + [], + true, + ); + + const steps = current.nextStepsForAppUpdate(defaultContext, target); + withSteps(steps).expectStep('createNetwork'); + withSteps(steps).rejectStep('start'); + }); // no create service, is create network +}); diff --git a/test/data/docker-states/entrypoint/inspect.json b/test/data/docker-states/entrypoint/inspect.json index 4aee08a9..db660118 100644 --- a/test/data/docker-states/entrypoint/inspect.json +++ b/test/data/docker-states/entrypoint/inspect.json @@ -24,7 +24,7 @@ "HostnamePath": "/var/lib/docker/containers/52cfd7a64d50236376741dd2578c4fbb0178d90e2e4fae55f3e14cd905e9ac9e/hostname", "HostsPath": "/var/lib/docker/containers/52cfd7a64d50236376741dd2578c4fbb0178d90e2e4fae55f3e14cd905e9ac9e/hosts", "LogPath": "", - "Name": "/nifty_swartz", + "Name": "main_1_1", "RestartCount": 0, "Driver": "aufs", "Platform": "linux", diff --git a/test/data/docker-states/networks/1623449_default.json b/test/data/docker-states/networks/1623449_default.json new file mode 100644 index 00000000..a5bc2291 --- /dev/null +++ b/test/data/docker-states/networks/1623449_default.json @@ -0,0 +1,30 @@ +{ + "Name": "1623449_default", + "Id": "4e6a4ae2dc07f09503c0ffa15b85e7e05cc7b80c0b38ba2e56f14fda4685bf5b", + "Created": "2020-06-11T09:04:00.299972855Z", + "Scope": "local", + "Driver": "bridge", + "EnableIPv6": false, + "IPAM": { + "Driver": "default", + "Options": {}, + "Config": [ + { + "Subnet": "172.17.0.0/16", + "Gateway": "172.17.0.1" + } + ] + }, + "Internal": false, + "Attachable": false, + "Ingress": false, + "ConfigFrom": { + "Network": "" + }, + "ConfigOnly": false, + "Containers": {}, + "Options": {}, + "Labels": { + "io.balena.supervised": "true" + } +} diff --git a/test/data/docker-states/supervisor-api/inspect.json b/test/data/docker-states/supervisor-api/inspect.json new file mode 100644 index 00000000..289ef9e9 --- /dev/null +++ b/test/data/docker-states/supervisor-api/inspect.json @@ -0,0 +1,270 @@ +{ + "Id": "4d325eab9c62f1f88525d8dbd13bb00cc3aac5fa6110d804eaf43266d858968b", + "Created": "2020-06-17T10:14:31.931188125Z", + "Path": "/usr/bin/entry.sh", + "Args": ["/bin/sh", "-c", "while true; do echo 'hello'; sleep 20; done;"], + "State": { + "Status": "running", + "Running": true, + "Paused": false, + "Restarting": false, + "OOMKilled": false, + "Dead": false, + "Pid": 2319, + "ExitCode": 0, + "Error": "", + "StartedAt": "2020-06-17T10:14:33.770739066Z", + "FinishedAt": "0001-01-01T00:00:00Z" + }, + "Image": "sha256:549682dc9a699a40ddcfe221f22c5d4d6603e068caa682121e69f1abc941bae0", + "ResolvConfPath": "/var/lib/docker/containers/4d325eab9c62f1f88525d8dbd13bb00cc3aac5fa6110d804eaf43266d858968b/resolv.conf", + "HostnamePath": "/var/lib/docker/containers/4d325eab9c62f1f88525d8dbd13bb00cc3aac5fa6110d804eaf43266d858968b/hostname", + "HostsPath": "/var/lib/docker/containers/4d325eab9c62f1f88525d8dbd13bb00cc3aac5fa6110d804eaf43266d858968b/hosts", + "LogPath": "", + "Name": "/main_2388278_1421617", + "RestartCount": 0, + "Driver": "overlay2", + "Platform": "linux", + "MountLabel": "", + "ProcessLabel": "", + "AppArmorProfile": "", + "ExecIDs": null, + "HostConfig": { + "Binds": [ + "/tmp/balena-supervisor/services/1623449/main:/tmp/resin", + "/tmp/balena-supervisor/services/1623449/main:/tmp/balena" + ], + "ContainerIDFile": "", + "ContainerIDEnv": "", + "LogConfig": { + "Type": "journald", + "Config": {} + }, + "NetworkMode": "1623449_default", + "PortBindings": {}, + "RestartPolicy": { + "Name": "always", + "MaximumRetryCount": 0 + }, + "AutoRemove": false, + "VolumeDriver": "", + "VolumesFrom": null, + "CapAdd": [], + "CapDrop": [], + "Dns": [], + "DnsOptions": [], + "DnsSearch": [], + "ExtraHosts": [], + "GroupAdd": [], + "IpcMode": "shareable", + "Cgroup": "", + "Links": null, + "OomScoreAdj": 0, + "PidMode": "", + "Privileged": false, + "PublishAllPorts": false, + "ReadonlyRootfs": false, + "SecurityOpt": [], + "UTSMode": "", + "UsernsMode": "", + "ShmSize": 67108864, + "Runtime": "runc", + "ConsoleSize": [0, 0], + "Isolation": "", + "CpuShares": 0, + "Memory": 0, + "NanoCpus": 0, + "CgroupParent": "", + "BlkioWeight": 0, + "BlkioWeightDevice": null, + "BlkioDeviceReadBps": null, + "BlkioDeviceWriteBps": null, + "BlkioDeviceReadIOps": null, + "BlkioDeviceWriteIOps": null, + "CpuPeriod": 0, + "CpuQuota": 0, + "CpuRealtimePeriod": 0, + "CpuRealtimeRuntime": 0, + "CpusetCpus": "", + "CpusetMems": "", + "Devices": [], + "DeviceCgroupRules": null, + "DiskQuota": 0, + "KernelMemory": 0, + "MemoryReservation": 0, + "MemorySwap": 0, + "MemorySwappiness": null, + "OomKillDisable": false, + "PidsLimit": 0, + "Ulimits": [], + "CpuCount": 0, + "CpuPercent": 0, + "IOMaximumIOps": 0, + "IOMaximumBandwidth": 0, + "MaskedPaths": [ + "/proc/asound", + "/proc/acpi", + "/proc/kcore", + "/proc/keys", + "/proc/latency_stats", + "/proc/timer_list", + "/proc/timer_stats", + "/proc/sched_debug", + "/proc/scsi", + "/sys/firmware" + ], + "ReadonlyPaths": [ + "/proc/bus", + "/proc/fs", + "/proc/irq", + "/proc/sys", + "/proc/sysrq-trigger" + ] + }, + "GraphDriver": { + "Data": { + "LowerDir": "/var/lib/docker/overlay2/10c7a6d2bb4964dfcf56637abc7cac14aef5e91f919bfc3b8c6670f0a121d6e8-init/diff:/var/lib/docker/overlay2/1283af7b9f4f78f77f252bedc9fed3cffa7dbd4469b3a46c5e65a4d6560ecf05/diff:/var/lib/docker/overlay2/5e80bdb80b81b3606edd4fb615d47d7638db9a5e28aee5899ac93f26efbb93cc/diff:/var/lib/docker/overlay2/dbd26995c3078afee336f098a872b862af1aa81c6d0c0f4e83d020dbbafca482/diff:/var/lib/docker/overlay2/75281533b8fe3398dc245e2165b103852c10110586270c0355c811ca18799a65/diff:/var/lib/docker/overlay2/42dadaf9781dc225f26bdf269f099e8a7fdd8318b9b45e1398c6033d2aca6833/diff:/var/lib/docker/overlay2/79aba86b843635a93f98825a1b9500589efbb01933f2e51c967c805b920840a8/diff:/var/lib/docker/overlay2/606f9266610966302adf26f227cd60b5a677166824456b0b1fa92f674ffd1d2d/diff:/var/lib/docker/overlay2/9997deced056506163efa614a837a9cf8ec6512a58ec19570d58f0ffa3185c1c/diff:/var/lib/docker/overlay2/17d564f6212e651d51fe346ae47ab5ebc21324a6815d010a5f119ab0d0b1ac06/diff", + "MergedDir": "/var/lib/docker/overlay2/10c7a6d2bb4964dfcf56637abc7cac14aef5e91f919bfc3b8c6670f0a121d6e8/merged", + "UpperDir": "/var/lib/docker/overlay2/10c7a6d2bb4964dfcf56637abc7cac14aef5e91f919bfc3b8c6670f0a121d6e8/diff", + "WorkDir": "/var/lib/docker/overlay2/10c7a6d2bb4964dfcf56637abc7cac14aef5e91f919bfc3b8c6670f0a121d6e8/work" + }, + "Name": "overlay2" + }, + "Mounts": [ + { + "Type": "bind", + "Source": "/tmp/balena-supervisor/services/1623449/main", + "Destination": "/tmp/balena", + "Mode": "", + "RW": true, + "Propagation": "rprivate" + }, + { + "Type": "bind", + "Source": "/tmp/balena-supervisor/services/1623449/main", + "Destination": "/tmp/resin", + "Mode": "", + "RW": true, + "Propagation": "rprivate" + } + ], + "Config": { + "Hostname": "4d325eab9c62", + "Domainname": "", + "User": "", + "AttachStdin": false, + "AttachStdout": false, + "AttachStderr": false, + "Tty": true, + "OpenStdin": false, + "StdinOnce": false, + "Env": [ + "RESIN_DEVICE_NAME_AT_INIT=new-device-name", + "BALENA_DEVICE_NAME_AT_INIT=new-device-name", + "BALENA_APP_ID=1623449", + "BALENA_APP_NAME=pi4test", + "BALENA_SERVICE_NAME=main", + "BALENA_DEVICE_UUID=fa93b70d5873e0be95f0e16be0235b73", + "BALENA_DEVICE_TYPE=raspberrypi4-64", + "BALENA_DEVICE_ARCH=aarch64", + "BALENA_HOST_OS_VERSION=balenaOS 2.48.0+rev1", + "BALENA_SUPERVISOR_VERSION=11.6.6", + "BALENA_APP_LOCK_PATH=/tmp/balena/updates.lock", + "BALENA=1", + "RESIN_APP_ID=1623449", + "RESIN_APP_NAME=pi4test", + "RESIN_SERVICE_NAME=main", + "RESIN_DEVICE_UUID=fa93b70d5873e0be95f0e16be0235b73", + "RESIN_DEVICE_TYPE=raspberrypi4-64", + "RESIN_DEVICE_ARCH=aarch64", + "RESIN_HOST_OS_VERSION=balenaOS 2.48.0+rev1", + "RESIN_SUPERVISOR_VERSION=11.6.6", + "RESIN_APP_LOCK_PATH=/tmp/balena/updates.lock", + "RESIN=1", + "RESIN_SERVICE_KILL_ME_PATH=/tmp/balena/handover-complete", + "BALENA_SERVICE_HANDOVER_COMPLETE_PATH=/tmp/balena/handover-complete", + "USER=root", + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "UDEV=off", + "RESIN_SUPERVISOR_PORT=48484", + "BALENA_SUPERVISOR_PORT=48484", + "RESIN_SUPERVISOR_API_KEY=c184f060f7862521fe915f323957157b", + "BALENA_SUPERVISOR_API_KEY=c184f060f7862521fe915f323957157b", + "RESIN_SUPERVISOR_HOST=127.0.0.1", + "BALENA_SUPERVISOR_HOST=127.0.0.1", + "RESIN_SUPERVISOR_ADDRESS=http://127.0.0.1:48484", + "BALENA_SUPERVISOR_ADDRESS=http://127.0.0.1:48484" + ], + "Cmd": ["/bin/sh", "-c", "while true; do echo 'hello'; sleep 20; done;"], + "Healthcheck": { + "Test": ["NONE"] + }, + "Image": "sha256:549682dc9a699a40ddcfe221f22c5d4d6603e068caa682121e69f1abc941bae0", + "Volumes": null, + "WorkingDir": "", + "Entrypoint": ["/usr/bin/entry.sh"], + "OnBuild": null, + "Labels": { + "io.balena.app-id": "1623449", + "io.balena.architecture": "aarch64", + "io.balena.features.supervisor-api": "1", + "io.balena.qemu.version": "4.0.0+balena2-aarch64", + "io.balena.service-id": "482141", + "io.balena.service-name": "main", + "io.balena.supervised": "true" + }, + "StopSignal": "SIGTERM", + "StopTimeout": 10 + }, + "NetworkSettings": { + "Bridge": "", + "SandboxID": "612f15c4bcb17961430159a0c363002fb17659577b5444dfbea65c738012e5a9", + "HairpinMode": false, + "LinkLocalIPv6Address": "", + "LinkLocalIPv6PrefixLen": 0, + "Ports": {}, + "SandboxKey": "/var/run/balena-engine/netns/612f15c4bcb1", + "SecondaryIPAddresses": null, + "SecondaryIPv6Addresses": null, + "EndpointID": "", + "Gateway": "", + "GlobalIPv6Address": "", + "GlobalIPv6PrefixLen": 0, + "IPAddress": "", + "IPPrefixLen": 0, + "IPv6Gateway": "", + "MacAddress": "", + "Networks": { + "1623449_default": { + "IPAMConfig": {}, + "Links": null, + "Aliases": ["main", "4d325eab9c62"], + "NetworkID": "4e6a4ae2dc07f09503c0ffa15b85e7e05cc7b80c0b38ba2e56f14fda4685bf5b", + "EndpointID": "1b053b592096ad3eb36070ca6966326a2f529e730bb77e1f4130b4385c1889a9", + "Gateway": "172.17.0.1", + "IPAddress": "172.17.0.2", + "IPPrefixLen": 16, + "IPv6Gateway": "", + "GlobalIPv6Address": "", + "GlobalIPv6PrefixLen": 0, + "MacAddress": "02:42:ac:11:00:02", + "DriverOpts": null + }, + "supervisor0": { + "IPAMConfig": {}, + "Links": null, + "Aliases": ["4d325eab9c62"], + "NetworkID": "e76c14514bc9b5967046e614b5f75193fb80370dc2ec210da6898afb8223959f", + "EndpointID": "d36e5e2b46270bc2618baf350c6b7af29e67b1a832e600669fb13a3468491ec0", + "Gateway": "10.114.104.1", + "IPAddress": "10.114.104.2", + "IPPrefixLen": 25, + "IPv6Gateway": "", + "GlobalIPv6Address": "", + "GlobalIPv6PrefixLen": 0, + "MacAddress": "02:42:0a:72:68:02", + "DriverOpts": null + } + } + } +} diff --git a/test/fast-mocha.opts b/test/fast-mocha.opts index 2b40ee68..94c67fee 100644 --- a/test/fast-mocha.opts +++ b/test/fast-mocha.opts @@ -1,4 +1,5 @@ --exit --require ts-node/register/transpile-only --timeout 30000 +--bail test/*.{ts,js} diff --git a/test/lib/application-state-mock.ts b/test/lib/application-state-mock.ts new file mode 100644 index 00000000..48ab202a --- /dev/null +++ b/test/lib/application-state-mock.ts @@ -0,0 +1,78 @@ +import * as networkManager from '../../src/compose/network-manager'; +import * as volumeManager from '../../src/compose/volume-manager'; +import * as serviceManager from '../../src/compose/service-manager'; +import * as imageManager from '../../src/compose/images'; + +import Service from '../../src/compose/service'; +import Network from '../../src/compose/network'; +import Volume from '../../src/compose/volume'; + +const originalVolGetAll = volumeManager.getAll; +const originalSvcGetAll = serviceManager.getAll; +const originalNetGetAll = networkManager.getAll; +const originalGetDl = imageManager.getDownloadingImageIds; +const originalNeedsClean = imageManager.isCleanupNeeded; +const originalImageAvailable = imageManager.getAvailable; +const originalNetworkReady = networkManager.supervisorNetworkReady; + +export function mockManagers(svcs: Service[], vols: Volume[], nets: Network[]) { + // @ts-expect-error Assigning to a RO property + volumeManager.getAll = async () => vols; + // @ts-expect-error Assigning to a RO property + networkManager.getAll = async () => nets; + // @ts-expect-error Assigning to a RO property + serviceManager.getAll = async () => { + console.log('Calling the mock', svcs); + return svcs; + }; +} + +function unmockManagers() { + // @ts-expect-error Assigning to a RO property + volumeManager.getAll = originalVolGetAll; + // @ts-expect-error Assigning to a RO property + networkManager.getAll = originalNetGetAll; + // @ts-expect-error Assigning to a RO property + serviceManager.getall = originalSvcGetAll; +} + +export function mockImages( + downloading: number[], + cleanup: boolean, + available: imageManager.Image[], +) { + // @ts-expect-error Assigning to a RO property + imageManager.getDownloadingImageIds = () => { + console.log('CALLED'); + return downloading; + }; + // @ts-expect-error Assigning to a RO property + imageManager.isCleanupNeeded = async () => cleanup; + // @ts-expect-error Assigning to a RO property + imageManager.getAvailable = async () => available; +} + +function unmockImages() { + // @ts-expect-error Assigning to a RO property + imageManager.getDownloadingImageIds = originalGetDl; + // @ts-expect-error Assigning to a RO property + imageManager.isCleanupNeeded = originalNeedsClean; + // @ts-expect-error Assigning to a RO property + imageManager.getAvailable = originalImageAvailable; +} + +export function mockSupervisorNetwork(exists: boolean) { + // @ts-expect-error Assigning to a RO property + networkManager.supervisorNetworkReady = async () => exists; +} + +function unmockSupervisorNetwork() { + // @ts-expect-error Assigning to a RO property + networkManager.supervisorNetworkReady = originalNetworkReady; +} + +export function unmockAll() { + unmockManagers(); + unmockImages(); + unmockSupervisorNetwork(); +} diff --git a/test/lib/mocked-device-api.ts b/test/lib/mocked-device-api.ts index 7d567dbe..4f570fc5 100644 --- a/test/lib/mocked-device-api.ts +++ b/test/lib/mocked-device-api.ts @@ -2,7 +2,7 @@ import * as _ from 'lodash'; import { Router } from 'express'; import { fs } from 'mz'; -import { ApplicationManager } from '../../src/application-manager'; +import * as applicationManager from '../../src/compose/application-manager'; import * as networkManager from '../../src/compose/network-manager'; import * as serviceManager from '../../src/compose/service-manager'; import * as volumeManager from '../../src/compose/volume-manager'; @@ -71,11 +71,10 @@ async function create(): Promise { // Stub functions setupStubs(); - // Create ApplicationManager - const appManager = new ApplicationManager(); + // Create SupervisorAPI const api = new SupervisorAPI({ - routers: [deviceState.router, buildRoutes(appManager)], + routers: [deviceState.router, buildRoutes()], healthchecks: [deviceState.healthcheck, apiBinder.healthcheck], }); @@ -120,13 +119,13 @@ async function initConfig(): Promise { }); } -function buildRoutes(appManager: ApplicationManager): Router { +function buildRoutes(): Router { // Create new Router const router = Router(); // Add V1 routes - createV1Api(router, appManager); + createV1Api(applicationManager.router); // Add V2 routes - createV2Api(router, appManager); + createV2Api(applicationManager.router); // Return modified Router return router; } diff --git a/test/lib/mocked-dockerode.ts b/test/lib/mocked-dockerode.ts index 315d53c7..835ac4f5 100644 --- a/test/lib/mocked-dockerode.ts +++ b/test/lib/mocked-dockerode.ts @@ -1,6 +1,58 @@ process.env.DOCKER_HOST = 'unix:///your/dockerode/mocks/are/not/working'; import * as dockerode from 'dockerode'; +import { Stream } from 'stream'; +import _ = require('lodash'); +import { TypedError } from 'typed-error'; + +export class NotFoundError extends TypedError { + public statusCode: number; + constructor() { + super(); + this.statusCode = 404; + } +} + +const overrides: Dictionary<(...args: any[]) => Resolvable> = {}; + +type DockerodeFunction = keyof dockerode; +for (const fn of Object.getOwnPropertyNames(dockerode.prototype)) { + if ( + fn !== 'constructor' && + typeof (dockerode.prototype as any)[fn] === 'function' + ) { + (dockerode.prototype as any)[fn] = async function (...args: any[]) { + console.log(`🐳 Calling ${fn}...`); + if (overrides[fn] != null) { + return overrides[fn](args); + } + + /* Return promise */ + return Promise.resolve([]); + }; + } +} + +// default overrides needed to startup... +registerOverride('listImages', async () => []); +registerOverride( + 'getEvents', + async () => + new Stream.Readable({ + read: () => { + return _.noop(); + }, + }), +); + +export function registerOverride< + T extends DockerodeFunction, + P extends Parameters, + R extends ReturnType +>(name: T, fn: (...args: P) => R) { + console.log(`Overriding ${name}...`); + overrides[name] = fn; +} export interface TestData { networks: Dictionary; diff --git a/typings/global.d.ts b/typings/global.d.ts index af59e4bc..32556afe 100644 --- a/typings/global.d.ts +++ b/typings/global.d.ts @@ -7,7 +7,7 @@ type Callback = (err?: Error, res?: T) => void; type Nullable = T | null | undefined; type Resolvable = T | Promise; -type UnwrappedPromise = T extends PromiseLike ? U : T; +type UnwrappedPromise = T extends PromiseLike ? U : T; type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial }