application-manager: Convert to a singleton

Change-type: patch
Signed-off-by: Rich Bayliss <rich@balena.io>
Signed-off-by: Cameron Diver <cameron@balena.io>
This commit is contained in:
Rich Bayliss 2020-08-13 13:25:39 +01:00
parent e3864915bc
commit 96c68166a1
No known key found for this signature in database
GPG Key ID: E53C4B4D18499E1A
45 changed files with 4048 additions and 2110 deletions

28
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -237,7 +237,7 @@ export async function patchDevice(
}
export async function provisionDependentDevice(
device: Device,
device: Partial<Device>,
): Promise<Device> {
const conf = await config.getMany([
'unmanaged',
@ -298,13 +298,13 @@ export async function fetchDeviceTags(): Promise<DeviceTag[]> {
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',
);
}
}

View File

@ -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<void>;
public getCurrentApp(appId: number): Promise<Application | null>;
// 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<void>;
public setTarget(
local: any,
dependent: any,
source: string,
transaction: Knex.Transaction,
): Promise<void>;
public getStatus(): Promise<{
local: DeviceStatus.local.apps;
dependent: DeviceStatus.dependent;
commit: DeviceStatus.commit;
}>;
// The return type is incompleted
public getTargetApps(): Promise<InstancedAppState>;
public stopAll(opts: { force?: boolean; skipLock?: boolean }): Promise<void>;
public serviceNameFromId(serviceId: number): Promise<string>;
public imageForService(svc: any): Image;
public getDependentTargets(): Promise<any>;
public getCurrentForComparison(): Promise<any>;
public getDependentState(): Promise<any>;
public getExtraStateForComparison(current: any, target: any): Promise<any>;
public getRequiredSteps(
currentState: any,
targetState: any,
extraState: any,
ignoreImages?: boolean,
): Promise<Array<CompositionStep<CompositionStepAction>>>;
public localModeSwitchCompletion(): Promise<void>;
}
export { ApplicationManager };

File diff suppressed because it is too large Load Diff

802
src/compose/app.ts Normal file
View File

@ -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<Volume>;
networks: Dictionary<Network>;
}
export interface UpdateState {
localMode: boolean;
availableImages: Image[];
containerIds: Dictionary<string>;
downloading: number[];
}
interface ChangingPair<T> {
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<Network>;
public volumes: Dictionary<Volume>;
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<UpdateState, 'availableImages'>,
): Promise<CompositionStep[]> {
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<T extends { isEqualConfig(target: T): boolean }>(
current: Dictionary<T>,
target: Dictionary<T>,
// Should this function issue remove steps? (we don't want to for volumes)
generateRemoves: boolean,
): Array<ChangingPair<T>> {
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<string>,
): {
installPairs: Array<ChangingPair<Service>>;
removePairs: Array<ChangingPair<Service>>;
updatePairs: Array<ChangingPair<Service>>;
} {
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<T extends Volume | Network>(
components: Array<ChangingPair<T>>,
changingServices: Array<ChangingPair<Service>>,
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<Service>,
context: {
localMode: boolean;
availableImages: Image[];
downloading: number[];
targetApp: App;
containerIds: Dictionary<string>;
networkPairs: Array<ChangingPair<Network>>;
volumePairs: Array<ChangingPair<Volume>>;
servicePairs: Array<ChangingPair<Service>>;
},
): Nullable<CompositionStep> {
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<ChangingPair<Service>>,
): 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<ChangingPair<Network>>,
volumePairs: Array<ChangingPair<Volume>>,
): 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<ChangingPair<Network>>,
volumePairs: Array<ChangingPair<Volume>>,
servicePairs: Array<ChangingPair<Service>>,
): 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<ChangingPair<Network>>,
volumePairs: Array<ChangingPair<Volume>>,
servicePairs: Array<ChangingPair<Service>>,
): 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<App> {
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;

View File

@ -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<Service['config']>;
} = {};
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<InstancedAppState>) {
events.emit('change', data ?? {});
}
export async function lockingIfNecessary<T extends unknown>(
appId: number,
{ force = false, skipLock = false } = {},
fn: () => Resolvable<T>,
) {
if (skipLock) {
return fn();
}
const lockOverride = (await config.get('lockOverride')) || force;
return updateLock.lock(
appId,
{ force: lockOverride },
fn as () => PromiseLike<void>,
);
}
export async function getRequiredSteps(
targetApps: InstancedAppState,
ignoreImages: boolean = false,
): Promise<CompositionStep[]> {
// 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<DeviceStatus['local']>['apps']
> {
const apps = await getCurrentApps();
const appsToReport: NonNullable<DeviceStatus['local']>['apps'] = {};
for (const appId of Object.getOwnPropertyNames(apps)) {
appsToReport[appId] = {
services: {},
};
}
return appsToReport;
}
export async function getCurrentApps(): Promise<InstancedAppState> {
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<void> {
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<TargetApplications> {
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<Service['config']>,
) {
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<imageManager.Image, 'dockerImageId' | 'id'>;
// 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<string> } = {};
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<any> = {};
const dependent: Dictionary<any> = {};
let releaseId: number | boolean | null | undefined = null; // ????
const creationTimesAndReleases: Dictionary<any> = {};
// 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 };
}

View File

@ -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<T extends CompositionStepAction> = {
export type CompositionStepT<T extends CompositionStepAction> = {
action: T;
} & CompositionStepArgs[T];
export type CompositionStep = CompositionStepT<CompositionStepAction>;
export function generateStep<T extends CompositionStepAction>(
action: T,
args: CompositionStepArgs[T],
): CompositionStep<T> {
): CompositionStep {
return {
action,
...args,
@ -112,7 +115,7 @@ export function generateStep<T extends CompositionStepAction>(
}
type Executors<T extends CompositionStepAction> = {
[key in T]: (step: CompositionStep<key>) => Promise<unknown>;
[key in T]: (step: CompositionStepT<key>) => Promise<unknown>;
};
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<unknown>) => 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<CompositionStepAction> = {
@ -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;

View File

@ -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<Image[]> {
);
}
// 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<void> {
@ -407,7 +421,8 @@ export async function inspectByName(
imageName: string,
): Promise<Docker.ImageInspectInfo> {
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<Image, 'name'>,
image2: Pick<Image, 'name'>,
): 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<string> {

View File

@ -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 {

View File

@ -55,9 +55,9 @@ let listening = false;
// we don't yet have an id)
const volatileState: Dictionary<Partial<Service>> = {};
export async function getAll(
export const getAll = async (
extraLabelFilters: string | string[] = [],
): Promise<Service[]> {
): Promise<Service[]> => {
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}`,
});
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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}`,
);
}
}

View File

@ -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

View File

@ -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<void>;
declare function doRestart(appId: number, force: boolean): Promise<void>;
declare function doPurge(
applications: ApplicationManager,
appId: number,
force: boolean,
): Promise<void>;
declare function doPurge(appId: number, force: boolean): Promise<void>;
declare function serviceAction(
action: string,

View File

@ -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,
});

View File

@ -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);
});

View File

@ -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<void> => {
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);

View File

@ -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<T extends PossibleStepTargets> =
}
| { action: 'shutdown' }
| { action: 'noop' }
| CompositionStep<T extends CompositionStepAction ? T : never>
| CompositionStepT<T extends CompositionStepAction ? T : never>
| 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<Promise<void>>;
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<T extends () => any, U extends ReturnType<T>>(
fn: T,
): Bluebird<UnwrappedPromise<U>> {
return Bluebird.using(readLockTarget, () => fn());
}
function usingWriteLockTarget(fn: () => any) {
function usingWriteLockTarget<T extends () => any, U extends ReturnType<T>>(
fn: T,
): Bluebird<UnwrappedPromise<U>> {
return Bluebird.using(writeLockTarget, () => fn());
}
function usingInferStepsLock(fn: () => any) {
function usingInferStepsLock<T extends () => any, U extends ReturnType<T>>(
fn: T,
): Bluebird<UnwrappedPromise<U>> {
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<InstancedDeviceState>;
});
}
export async function getStatus(): Promise<DeviceStatus> {
const appsStatus = await applications.getStatus();
const appsStatus = await applicationManager.getStatus();
const theState: DeepPartial<DeviceStatus> = {
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<InstancedDeviceState> {
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<InstancedAppState> = {},
) {
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<T extends PossibleStepTargets>(
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;
});

View File

@ -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<InstancedApp> {
const dbApp = await getDBEntry(id);
return await buildApp(dbApp);
return await App.fromTargetState(dbApp);
}
export async function getApps(): Promise<InstancedAppState> {
@ -39,110 +30,12 @@ export async function getApps(): Promise<InstancedAppState> {
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<Service>;
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<TargetApplications> {
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<targetStateCache.DatabaseApp[]>;
function getDBEntry(appId: number): Promise<targetStateCache.DatabaseApp>;
async function getDBEntry(appId?: number) {

View File

@ -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));
}
}

View File

@ -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));

View File

@ -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/`,
);
});
});
}

View File

@ -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());

View File

@ -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;

View File

@ -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<string>;
@ -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<TargetState['local'], 'name'> & {
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<Service>;
volumes: Dictionary<Volume>;
networks: Dictionary<Network>;
};
}
export type InstancedAppState = { [appId: number]: App };
export interface InstancedDeviceState {
local: {

View File

@ -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';

View File

@ -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,

View File

@ -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[] = [];

View File

@ -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 () {

View File

@ -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();

View File

@ -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,

View File

@ -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']);

View File

@ -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',
});
});
});
});
});

1039
test/34-compose-app.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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"
}
}

View File

@ -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
}
}
}
}

View File

@ -1,4 +1,5 @@
--exit
--require ts-node/register/transpile-only
--timeout 30000
--bail
test/*.{ts,js}

View File

@ -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();
}

View File

@ -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<SupervisorAPI> {
// 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<void> {
});
}
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;
}

View File

@ -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<any>> = {};
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<dockerode[T]>,
R extends ReturnType<dockerode[T]>
>(name: T, fn: (...args: P) => R) {
console.log(`Overriding ${name}...`);
overrides[name] = fn;
}
export interface TestData {
networks: Dictionary<any>;

2
typings/global.d.ts vendored
View File

@ -7,7 +7,7 @@ type Callback<T> = (err?: Error, res?: T) => void;
type Nullable<T> = T | null | undefined;
type Resolvable<T> = T | Promise<T>;
type UnwrappedPromise = T extends PromiseLike<infer U> ? U : T;
type UnwrappedPromise<T> = T extends PromiseLike<infer U> ? U : T;
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }