Merge pull request #1408 from balena-io/refactor-to-singletons

Refactor to singletons
This commit is contained in:
bulldozer-balena[bot] 2020-09-14 12:01:31 +00:00 committed by GitHub
commit 4a5874c510
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 5726 additions and 3649 deletions

28
package-lock.json generated
View File

@ -403,6 +403,24 @@
"@types/chai": "*" "@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": { "@types/color-name": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
@ -2139,6 +2157,16 @@
"chai": "^3.5.0" "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": { "chalk": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",

View File

@ -27,6 +27,8 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"chai-like": "^1.1.1",
"chai-things": "^0.2.0",
"dbus": "^1.0.7", "dbus": "^1.0.7",
"mdns-resolver": "^1.0.0", "mdns-resolver": "^1.0.0",
"os-utils": "0.0.14", "os-utils": "0.0.14",
@ -44,6 +46,8 @@
"@types/bluebird": "^3.5.32", "@types/bluebird": "^3.5.32",
"@types/chai": "^4.2.12", "@types/chai": "^4.2.12",
"@types/chai-as-promised": "^7.1.3", "@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/common-tags": "^1.8.0",
"@types/copy-webpack-plugin": "^6.0.0", "@types/copy-webpack-plugin": "^6.0.0",
"@types/dbus": "^1.0.0", "@types/dbus": "^1.0.0",

File diff suppressed because it is too large Load Diff

View File

@ -1,100 +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 DeviceState from './device-state';
import { 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 deviceState: DeviceState;
public apiBinder: APIBinder;
public proxyvisor: any;
public timeSpentFetching: number;
public fetchesInProgress: number;
public validActions: string[];
public router: Router;
public constructor({ deviceState: DeviceState, apiBinder: APIBinder });
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 * as config from '../config';
import { ApplicationManager } from '../application-manager'; import * as applicationManager from './application-manager';
import type { Image } from './images'; import type { Image } from './images';
import * as images from './images'; import * as images from './images';
import Network from './network'; import Network from './network';
@ -13,6 +13,7 @@ import Volume from './volume';
import { checkTruthy } from '../lib/validation'; import { checkTruthy } from '../lib/validation';
import * as networkManager from './network-manager'; import * as networkManager from './network-manager';
import * as volumeManager from './volume-manager'; import * as volumeManager from './volume-manager';
import { DeviceReportFields } from '../types/state';
interface BaseCompositionStepArgs { interface BaseCompositionStepArgs {
force?: boolean; force?: boolean;
@ -36,7 +37,6 @@ interface CompositionStepArgs {
options?: { options?: {
skipLock?: boolean; skipLock?: boolean;
wait?: boolean; wait?: boolean;
removeImage?: boolean;
}; };
} & BaseCompositionStepArgs; } & BaseCompositionStepArgs;
remove: { remove: {
@ -44,7 +44,7 @@ interface CompositionStepArgs {
} & BaseCompositionStepArgs; } & BaseCompositionStepArgs;
updateMetadata: { updateMetadata: {
current: Service; current: Service;
target: { imageId: number; releaseId: number }; target: Service;
options?: { options?: {
skipLock?: boolean; skipLock?: boolean;
}; };
@ -68,6 +68,7 @@ interface CompositionStepArgs {
target: Service; target: Service;
options?: { options?: {
skipLock?: boolean; skipLock?: boolean;
timeout?: number;
}; };
} & BaseCompositionStepArgs; } & BaseCompositionStepArgs;
fetch: { fetch: {
@ -94,17 +95,19 @@ interface CompositionStepArgs {
current: Volume; current: Volume;
}; };
ensureSupervisorNetwork: {}; ensureSupervisorNetwork: {};
noop: {};
} }
export type CompositionStepAction = keyof CompositionStepArgs; export type CompositionStepAction = keyof CompositionStepArgs;
export type CompositionStep<T extends CompositionStepAction> = { export type CompositionStepT<T extends CompositionStepAction> = {
action: T; action: T;
} & CompositionStepArgs[T]; } & CompositionStepArgs[T];
export type CompositionStep = CompositionStepT<CompositionStepAction>;
export function generateStep<T extends CompositionStepAction>( export function generateStep<T extends CompositionStepAction>(
action: T, action: T,
args: CompositionStepArgs[T], args: CompositionStepArgs[T],
): CompositionStep<T> { ): CompositionStep {
return { return {
action, action,
...args, ...args,
@ -112,7 +115,7 @@ export function generateStep<T extends CompositionStepAction>(
} }
type Executors<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 = ( type LockingFn = (
// TODO: Once the entire codebase is typescript, change // TODO: Once the entire codebase is typescript, change
@ -130,13 +133,12 @@ interface CompositionCallbacks {
fetchStart: () => void; fetchStart: () => void;
fetchEnd: () => void; fetchEnd: () => void;
fetchTime: (time: number) => void; fetchTime: (time: number) => void;
stateReport: (state: Dictionary<unknown>) => boolean; stateReport: (state: DeviceReportFields) => void;
bestDeltaSource: (image: Image, available: Image[]) => string | null; bestDeltaSource: (image: Image, available: Image[]) => string | null;
} }
export function getExecutors(app: { export function getExecutors(app: {
lockFn: LockingFn; lockFn: LockingFn;
applications: ApplicationManager;
callbacks: CompositionCallbacks; callbacks: CompositionCallbacks;
}) { }) {
const executors: Executors<CompositionStepAction> = { const executors: Executors<CompositionStepAction> = {
@ -167,9 +169,6 @@ export function getExecutors(app: {
async () => { async () => {
await serviceManager.kill(step.current); await serviceManager.kill(step.current);
app.callbacks.containerKilled(step.current.containerId); 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) => { stopAll: async (step) => {
await app.applications.stopAll({ await applicationManager.stopAll({
force: step.force, force: step.force,
skipLock: step.skipLock, skipLock: step.skipLock,
}); });
@ -259,7 +258,7 @@ export function getExecutors(app: {
// been downloaded ,and it's relevant mostly for // been downloaded ,and it's relevant mostly for
// the legacy GET /v1/device endpoint that assumes // the legacy GET /v1/device endpoint that assumes
// a single container app // a single container app
await app.callbacks.stateReport({ update_downloaded: true }); app.callbacks.stateReport({ update_downloaded: true });
} }
}, },
step.serviceName, step.serviceName,
@ -292,6 +291,9 @@ export function getExecutors(app: {
ensureSupervisorNetwork: async () => { ensureSupervisorNetwork: async () => {
networkManager.ensureSupervisorNetwork(); networkManager.ensureSupervisorNetwork();
}, },
noop: async () => {
/* async noop */
},
}; };
return executors; return executors;

View File

@ -20,6 +20,8 @@ import * as validation from '../lib/validation';
import * as logger from '../logger'; import * as logger from '../logger';
import { ImageDownloadBackoffError } from './errors'; import { ImageDownloadBackoffError } from './errors';
import type { Service } from './service';
import log from '../lib/supervisor-console'; import log from '../lib/supervisor-console';
interface FetchProgressEvent { 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( export async function triggerFetch(
image: Image, image: Image,
opts: FetchOptions, opts: FetchOptions,
@ -263,15 +282,10 @@ export async function getAvailable(): Promise<Image[]> {
); );
} }
// TODO: Why does this need a Bluebird.try? export function getDownloadingImageIds(): number[] {
export function getDownloadingImageIds() { return _.keys(_.pickBy(volatileState, { status: 'Downloading' })).map((i) =>
return Bluebird.try(() => validation.checkInt(i),
_(volatileState) ) as number[];
.pickBy({ status: 'Downloading' })
.keys()
.map(validation.checkInt)
.value(),
);
} }
export async function cleanupDatabase(): Promise<void> { export async function cleanupDatabase(): Promise<void> {
@ -407,7 +421,8 @@ export async function inspectByName(
imageName: string, imageName: string,
): Promise<Docker.ImageInspectInfo> { ): Promise<Docker.ImageInspectInfo> {
try { try {
return await docker.getImage(imageName).inspect(); const image = await docker.getImage(imageName);
return await image.inspect();
} catch (e) { } catch (e) {
if (NotFoundError(e)) { if (NotFoundError(e)) {
const digest = imageName.split('@')[1]; const digest = imageName.split('@')[1];
@ -459,7 +474,9 @@ export function isSameImage(
image1: Pick<Image, 'name'>, image1: Pick<Image, 'name'>,
image2: Pick<Image, 'name'>, image2: Pick<Image, 'name'>,
): boolean { ): 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> { export function normalise(imageName: string): Bluebird<string> {

View File

@ -175,16 +175,23 @@ export class Network {
network: { name: this.name, appId: this.appId }, network: { name: this.name, appId: this.appId },
}); });
return Bluebird.resolve( const networkName = Network.generateDockerName(this.appId, this.name);
docker
.getNetwork(Network.generateDockerName(this.appId, this.name)) return Bluebird.resolve(docker.listNetworks())
.remove(), .then((networks) => networks.filter((n) => n.Name === networkName))
).tapCatch((error) => { .then(([network]) => {
logger.logSystemEvent(logTypes.removeNetworkError, { if (!network) {
network: { name: this.name, appId: this.appId }, return Bluebird.resolve();
error, }
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 { public isEqualConfig(network: Network): boolean {

View File

@ -55,9 +55,9 @@ let listening = false;
// we don't yet have an id) // we don't yet have an id)
const volatileState: Dictionary<Partial<Service>> = {}; const volatileState: Dictionary<Partial<Service>> = {};
export async function getAll( export const getAll = async (
extraLabelFilters: string | string[] = [], extraLabelFilters: string | string[] = [],
): Promise<Service[]> { ): Promise<Service[]> => {
const filterLabels = ['supervised'].concat(extraLabelFilters); const filterLabels = ['supervised'].concat(extraLabelFilters);
const containers = await listWithBothLabels(filterLabels); const containers = await listWithBothLabels(filterLabels);
@ -81,7 +81,7 @@ export async function getAll(
}); });
return services.filter((s) => s != null) as Service[]; return services.filter((s) => s != null) as Service[];
} };
export async function get(service: Service) { export async function get(service: Service) {
// Get the container ids for special network handling // Get the container ids for special network handling
@ -141,10 +141,7 @@ export async function getByDockerContainerId(
return Service.fromDockerContainer(container); return Service.fromDockerContainer(container);
} }
export async function updateMetadata( export async function updateMetadata(service: Service, target: Service) {
service: Service,
metadata: { imageId: number; releaseId: number },
) {
const svc = await get(service); const svc = await get(service);
if (svc.containerId == null) { if (svc.containerId == null) {
throw new InternalInconsistencyError( throw new InternalInconsistencyError(
@ -153,7 +150,7 @@ export async function updateMetadata(
} }
await docker.getContainer(svc.containerId).rename({ 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 * as conversions from '../lib/conversions';
import { checkInt } from '../lib/validation'; import { checkInt } from '../lib/validation';
import { InternalInconsistencyError } from '../lib/errors';
import { DockerPortOptions, PortMap } from './ports'; import { DockerPortOptions, PortMap } from './ports';
import { import {
ConfigMap, ConfigMap,
@ -27,19 +28,37 @@ import { EnvVarObject } from '../lib/types';
const SERVICE_NETWORK_MODE_REGEX = /service:\s*(.+)/; const SERVICE_NETWORK_MODE_REGEX = /service:\s*(.+)/;
const CONTAINER_NETWORK_MODE_REGEX = /container:\s*(.+)/; const CONTAINER_NETWORK_MODE_REGEX = /container:\s*(.+)/;
export type ServiceStatus =
| 'Stopping'
| 'Stopped'
| 'Running'
| 'Installing'
| 'Installed'
| 'Dead'
| 'paused'
| 'restarting'
| 'removing'
| 'exited';
export class Service { export class Service {
public appId: number | null; public appId: number;
public imageId: number | null; public imageId: number;
public config: ServiceConfig; public config: ServiceConfig;
public serviceName: string | null; public serviceName: string | null;
public releaseId: number | null; public releaseId: number;
public serviceId: number | null; public serviceId: number;
public imageName: string | null; public imageName: string | null;
public containerId: string | null; public containerId: string | null;
public dependsOn: 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; public createdAt: Date | null;
private static configArrayFields: ServiceConfigArrayField[] = [ private static configArrayFields: ServiceConfigArrayField[] = [
@ -89,23 +108,25 @@ export class Service {
appConfig = ComposeUtils.camelCaseConfig(appConfig); appConfig = ComposeUtils.camelCaseConfig(appConfig);
const intOrNull = ( if (!appConfig.appId) {
val: string | number | null | undefined, throw new InternalInconsistencyError('No app id for service');
): number | null => { }
return checkInt(val) || null; const appId = checkInt(appConfig.appId);
}; if (appId == null) {
throw new InternalInconsistencyError('Malformed app id for service');
}
// Seperate the application information from the docker // Seperate the application information from the docker
// container configuration // container configuration
service.imageId = intOrNull(appConfig.imageId); service.imageId = parseInt(appConfig.imageId, 10);
delete appConfig.imageId; delete appConfig.imageId;
service.serviceName = appConfig.serviceName; service.serviceName = appConfig.serviceName;
delete appConfig.serviceName; delete appConfig.serviceName;
service.appId = intOrNull(appConfig.appId); service.appId = appId;
delete appConfig.appId; delete appConfig.appId;
service.releaseId = intOrNull(appConfig.releaseId); service.releaseId = parseInt(appConfig.releaseId, 10);
delete appConfig.releaseId; delete appConfig.releaseId;
service.serviceId = intOrNull(appConfig.serviceId); service.serviceId = parseInt(appConfig.serviceId, 10);
delete appConfig.serviceId; delete appConfig.serviceId;
service.imageName = appConfig.image; service.imageName = appConfig.image;
service.dependsOn = appConfig.dependsOn || null; service.dependsOn = appConfig.dependsOn || null;
@ -282,7 +303,7 @@ export class Service {
config.volumes = Service.extendAndSanitiseVolumes( config.volumes = Service.extendAndSanitiseVolumes(
config.volumes, config.volumes,
options.imageInfo, options.imageInfo,
service.appId || 0, service.appId,
service.serviceName || '', service.serviceName || '',
); );
@ -439,7 +460,9 @@ export class Service {
} else if (container.State.Status === 'dead') { } else if (container.State.Status === 'dead') {
svc.status = 'Dead'; svc.status = 'Dead';
} else { } 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); svc.createdAt = new Date(container.Created);
@ -560,22 +583,44 @@ export class Service {
tty: container.Config.Tty || false, tty: container.Config.Tty || false,
}; };
svc.appId = checkInt(svc.config.labels['io.balena.app-id']) || null; const appId = checkInt(svc.config.labels['io.balena.app-id']);
svc.serviceId = checkInt(svc.config.labels['io.balena.service-id']) || null; 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.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+)$/); 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.imageId = parseInt(nameMatch[1], 10);
svc.releaseId = nameMatch != null ? checkInt(nameMatch[2]) || null : null; svc.releaseId = parseInt(nameMatch[2], 10);
svc.containerId = container.Id; svc.containerId = container.Id;
return svc; return svc;
} }
public toComposeObject(): ServiceConfig { /**
// This isn't techinically correct as we do some changes * Here we try to reverse the fromComposeObject to the best of our ability, as
// to the configuration which we cannot reverse. We also * this is used for the supervisor reporting it's own target state. Some of
// represent the ports as a class, which isn't ideal * 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; return this.config;
} }

View File

@ -184,7 +184,7 @@ export interface ConfigMap {
// is typescript // is typescript
export interface DeviceMetadata { export interface DeviceMetadata {
imageInfo?: Dockerode.ImageInspectInfo; imageInfo?: Dockerode.ImageInspectInfo;
uuid: string; uuid: string | null;
appName: string; appName: string;
version: string; version: string;
deviceType: 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 // TODO: Export these strings to a constant lib, to
// enable changing them easily // enable changing them easily
// Mutates service // Mutates service

View File

@ -1,4 +1,3 @@
import ApplicationManager from '../application-manager';
import { Service } from '../compose/service'; import { Service } from '../compose/service';
import { InstancedDeviceState } from '../types/state'; import { InstancedDeviceState } from '../types/state';
@ -10,17 +9,9 @@ export interface ServiceAction {
options: any; options: any;
} }
declare function doRestart( declare function doRestart(appId: number, force: boolean): Promise<void>;
applications: ApplicationManager,
appId: number,
force: boolean,
): Promise<void>;
declare function doPurge( declare function doPurge(appId: number, force: boolean): Promise<void>;
applications: ApplicationManager,
appId: number,
force: boolean,
): Promise<void>;
declare function serviceAction( declare function serviceAction(
action: string, action: string,

View File

@ -3,26 +3,38 @@ import * as _ from 'lodash';
import { appNotFoundMessage } from '../lib/messages'; import { appNotFoundMessage } from '../lib/messages';
import * as logger from '../logger'; 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) { export async function doRestart(appId, force) {
const { _lockingIfNecessary, deviceState } = applications; await deviceState.initialized;
await applicationManager.initialized;
return _lockingIfNecessary(appId, { force }, () => const { lockingIfNecessary } = applicationManager;
deviceState.getCurrentForComparison().then(function (currentState) {
const app = safeAppClone(currentState.local.apps[appId]); 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'); const imageIds = _.map(app.services, 'imageId');
applications.clearTargetVolatileForServices(imageIds); applicationManager.clearTargetVolatileForServices(imageIds);
const stoppedApp = _.cloneDeep(app); const currentServices = app.services;
stoppedApp.services = []; app.services = [];
currentState.local.apps[appId] = stoppedApp;
return deviceState return deviceState
.pausingApply(() => .pausingApply(() =>
deviceState deviceState
.applyIntermediateTarget(currentState, { skipLock: true }) .applyIntermediateTarget(currentState, { skipLock: true })
.then(function () { .then(function () {
currentState.local.apps[appId] = app; app.services = currentServices;
return deviceState.applyIntermediateTarget(currentState, { return deviceState.applyIntermediateTarget(currentState, {
skipLock: true, skipLock: true,
}); });
@ -33,25 +45,33 @@ export function doRestart(applications, appId, force) {
); );
} }
export function doPurge(applications, appId, force) { export async function doPurge(appId, force) {
const { _lockingIfNecessary, deviceState } = applications; await deviceState.initialized;
await applicationManager.initialized;
const { lockingIfNecessary } = applicationManager;
logger.logSystemMessage( logger.logSystemMessage(
`Purging data for app ${appId}`, `Purging data for app ${appId}`,
{ appId }, { appId },
'Purge data', 'Purge data',
); );
return _lockingIfNecessary(appId, { force }, () => return lockingIfNecessary(appId, { force }, () =>
deviceState.getCurrentForComparison().then(function (currentState) { deviceState.getCurrentState().then(function (currentState) {
if (currentState.local.apps[appId] == null) { const allApps = currentState.local.apps;
if (allApps?.[appId] == null) {
throw new Error(appNotFoundMessage); throw new Error(appNotFoundMessage);
} }
const app = safeAppClone(currentState.local.apps[appId]);
const purgedApp = _.cloneDeep(app); const app = allApps[appId];
purgedApp.services = [];
purgedApp.volumes = {}; const currentServices = app.services;
currentState.local.apps[appId] = purgedApp; const currentVolumes = app.volumes;
app.services = [];
app.volumes = {};
return deviceState return deviceState
.pausingApply(() => .pausingApply(() =>
deviceState deviceState
@ -61,12 +81,13 @@ export function doPurge(applications, appId, force) {
// remove the volumes, we must do this here, as the // remove the volumes, we must do this here, as the
// application-manager will not remove any volumes // application-manager will not remove any volumes
// which are part of an active application // which are part of an active application
return Bluebird.each(volumes.getAllByAppId(appId), (vol) => return Bluebird.each(volumeManager.getAllByAppId(appId), (vol) =>
vol.remove(), vol.remove(),
); );
}) })
.then(function () { .then(() => {
currentState.local.apps[appId] = app; app.services = currentServices;
app.volumes = currentVolumes;
return deviceState.applyIntermediateTarget(currentState, { return deviceState.applyIntermediateTarget(currentState, {
skipLock: true, skipLock: true,
}); });

View File

@ -4,9 +4,12 @@ import * as _ from 'lodash';
import * as eventTracker from '../event-tracker'; import * as eventTracker from '../event-tracker';
import * as constants from '../lib/constants'; import * as constants from '../lib/constants';
import { checkInt, checkTruthy } from '../lib/validation'; 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) { router.post('/v1/restart', function (req, res, next) {
const appId = checkInt(req.body.appId); const appId = checkInt(req.body.appId);
const force = checkTruthy(req.body.force) ?? false; const force = checkTruthy(req.body.force) ?? false;
@ -14,7 +17,7 @@ export const createV1Api = function (router, applications) {
if (appId == null) { if (appId == null) {
return res.status(400).send('Missing app id'); return res.status(400).send('Missing app id');
} }
return doRestart(applications, appId, force) return doRestart(appId, force)
.then(() => res.status(200).send('OK')) .then(() => res.status(200).send('OK'))
.catch(next); .catch(next);
}); });
@ -25,13 +28,18 @@ export const createV1Api = function (router, applications) {
if (appId == null) { if (appId == null) {
return res.status(400).send('Missing app id'); return res.status(400).send('Missing app id');
} }
return applications
.getCurrentApp(appId) return applicationManager
.then(function (app) { .getCurrentApps()
let service = app?.services?.[0]; .then(function (apps) {
if (service == null) { if (apps[appId] == null) {
return res.status(400).send('App not found'); 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) { if (app.services.length > 1) {
return res return res
.status(400) .status(400)
@ -39,23 +47,21 @@ export const createV1Api = function (router, applications) {
'Some v1 endpoints are only allowed on single-container apps', 'Some v1 endpoints are only allowed on single-container apps',
); );
} }
applications.setTargetVolatileForService(service.imageId, { applicationManager.setTargetVolatileForService(service.imageId, {
running: action !== 'stop', running: action !== 'stop',
}); });
return applications return applicationManager
.executeStepAction( .executeStep(generateStep(action, { current: service, wait: true }), {
serviceAction(action, service.serviceId, service, service, { force,
wait: true, })
}),
{ force },
)
.then(function () { .then(function () {
if (action === 'stop') { if (action === 'stop') {
return service; return service;
} }
// We refresh the container id in case we were starting an app with no container yet // We refresh the container id in case we were starting an app with no container yet
return applications.getCurrentApp(appId).then(function (app2) { return applicationManager.getCurrentApps().then(function (apps2) {
service = app2?.services?.[0]; const app2 = apps2[appId];
service = app2.services[0];
if (service == null) { if (service == null) {
throw new Error('App not found after running action'); 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 res.status(400).send('Missing app id');
} }
return Promise.join( return Promise.join(
applications.getCurrentApp(appId), applicationManager.getCurrentApps(),
applications.getStatus(), applicationManager.getStatus(),
function (app, status) { function (apps, status) {
const app = apps[appId];
const service = app?.services?.[0]; const service = app?.services?.[0];
if (service == null) { if (service == null) {
return res.status(400).send('App not found'); return res.status(400).send('App not found');
@ -100,9 +107,9 @@ export const createV1Api = function (router, applications) {
const appToSend = { const appToSend = {
appId, appId,
containerId: service.containerId, containerId: service.containerId,
env: _.omit(service.environment, constants.privateAppEnvVars), env: _.omit(service.config.environment, constants.privateAppEnvVars),
releaseId: service.releaseId, releaseId: service.releaseId,
imageId: service.image, imageId: service.config.image,
}; };
if (status.commit != null) { if (status.commit != null) {
appToSend.commit = status.commit; appToSend.commit = status.commit;
@ -119,7 +126,7 @@ export const createV1Api = function (router, applications) {
const errMsg = 'Invalid or missing appId'; const errMsg = 'Invalid or missing appId';
return res.status(400).send(errMsg); return res.status(400).send(errMsg);
} }
return doPurge(applications, appId, force) return doPurge(appId, force)
.then(() => res.status(200).json({ Data: 'OK', Error: '' })) .then(() => res.status(200).json({ Data: 'OK', Error: '' }))
.catch(next); .catch(next);
}); });

View File

@ -2,7 +2,14 @@ import * as Bluebird from 'bluebird';
import { NextFunction, Request, Response, Router } from 'express'; import { NextFunction, Request, Response, Router } from 'express';
import * as _ from 'lodash'; 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 { Service } from '../compose/service';
import Volume from '../compose/volume'; import Volume from '../compose/volume';
import * as config from '../config'; import * as config from '../config';
@ -22,16 +29,14 @@ import log from '../lib/supervisor-console';
import supervisorVersion = require('../lib/supervisor-version'); import supervisorVersion = require('../lib/supervisor-version');
import { checkInt, checkTruthy } from '../lib/validation'; import { checkInt, checkTruthy } from '../lib/validation';
import { isVPNActive } from '../network'; import { isVPNActive } from '../network';
import { doPurge, doRestart, safeStateClone, serviceAction } from './common'; import { doPurge, doRestart, safeStateClone } from './common';
export function createV2Api(router: Router, applications: ApplicationManager) {
const { _lockingIfNecessary, deviceState } = applications;
export function createV2Api(router: Router) {
const handleServiceAction = ( const handleServiceAction = (
req: Request, req: Request,
res: Response, res: Response,
next: NextFunction, next: NextFunction,
action: any, action: CompositionStepAction,
): Resolvable<void> => { ): Resolvable<void> => {
const { imageId, serviceName, force } = req.body; const { imageId, serviceName, force } = req.body;
const appId = checkInt(req.params.appId); const appId = checkInt(req.params.appId);
@ -43,10 +48,11 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
return; return;
} }
return _lockingIfNecessary(appId, { force }, () => { return applicationManager.lockingIfNecessary(appId, { force }, () => {
return applications return Promise.all([applicationManager.getCurrentApps(), getApp(appId)])
.getCurrentApp(appId) .then(([apps, targetApp]) => {
.then((app) => { const app = apps[appId];
if (app == null) { if (app == null) {
res.status(404).send(appNotFoundMessage); res.status(404).send(appNotFoundMessage);
return; return;
@ -60,27 +66,41 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
} }
let service: Service | undefined; let service: Service | undefined;
let targetService: Service | undefined;
if (imageId != null) { if (imageId != null) {
service = _.find(app.services, (svc) => svc.imageId === imageId); service = _.find(app.services, (svc) => svc.imageId === imageId);
targetService = _.find(
targetApp.services,
(svc) => svc.imageId === imageId,
);
} else { } else {
service = _.find( service = _.find(
app.services, app.services,
(svc) => svc.serviceName === serviceName, (svc) => svc.serviceName === serviceName,
); );
targetService = _.find(
targetApp.services,
(svc) => svc.serviceName === serviceName,
);
} }
if (service == null) { if (service == null) {
res.status(404).send(serviceNotFoundMessage); res.status(404).send(serviceNotFoundMessage);
return; return;
} }
applications.setTargetVolatileForService(service.imageId!, {
applicationManager.setTargetVolatileForService(service.imageId!, {
running: action !== 'stop', running: action !== 'stop',
}); });
return applications return applicationManager
.executeStepAction( .executeStep(
serviceAction(action, service.serviceId!, service, service, { generateStep(action, {
current: service,
target: targetService,
wait: true, wait: true,
}), }),
{ skipLock: true }, {
skipLock: true,
},
) )
.then(() => { .then(() => {
res.status(200).send('OK'); res.status(200).send('OK');
@ -105,7 +125,7 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
}); });
} }
return doPurge(applications, appId, force) return doPurge(appId, force)
.then(() => { .then(() => {
res.status(200).send('OK'); res.status(200).send('OK');
}) })
@ -140,7 +160,7 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
}); });
} }
return doRestart(applications, appId, force) return doRestart(appId, force)
.then(() => { .then(() => {
res.status(200).send('OK'); res.status(200).send('OK');
}) })
@ -239,7 +259,7 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
// Query device for all applications // Query device for all applications
let apps: any; let apps: any;
try { try {
apps = await applications.getStatus(); apps = await applicationManager.getStatus();
} catch (e) { } catch (e) {
log.error(e.message); log.error(e.message);
return res.status(500).json({ return res.status(500).json({
@ -334,7 +354,7 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
if (id in serviceNameCache) { if (id in serviceNameCache) {
return serviceNameCache[id]; return serviceNameCache[id];
} else { } else {
const name = await applications.serviceNameFromId(id); const name = await applicationManager.serviceNameFromId(id);
serviceNameCache[id] = name; serviceNameCache[id] = name;
return name; return name;
} }
@ -394,7 +414,7 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
router.get('/v2/state/status', async (_req, res) => { router.get('/v2/state/status', async (_req, res) => {
const currentRelease = await config.get('currentCommit'); const currentRelease = await config.get('currentCommit');
const pending = applications.deviceState.applyInProgress; const pending = deviceState.isApplyInProgress();
const containerStates = (await serviceManager.getAll()).map((svc) => const containerStates = (await serviceManager.getAll()).map((svc) =>
_.pick( _.pick(
svc, svc,
@ -452,12 +472,13 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
router.get('/v2/device/tags', async (_req, res) => { router.get('/v2/device/tags', async (_req, res) => {
try { try {
const tags = await applications.apiBinder.fetchDeviceTags(); const tags = await apiBinder.fetchDeviceTags();
return res.json({ return res.json({
status: 'success', status: 'success',
tags, tags,
}); });
} catch (e) { } catch (e) {
log.error(e);
res.status(500).json({ res.status(500).json({
status: 'failed', status: 'failed',
message: e.message, message: e.message,
@ -480,11 +501,13 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
}); });
router.get('/v2/cleanup-volumes', async (_req, res) => { router.get('/v2/cleanup-volumes', async (_req, res) => {
const targetState = await applications.getTargetApps(); const targetState = await applicationManager.getTargetApps();
const referencedVolumes: string[] = []; const referencedVolumes: string[] = [];
_.each(targetState, (app) => { _.each(targetState, (app, appId) => {
_.each(app.volumes, (vol) => { _.each(app.volumes, (_volume, volumeName) => {
referencedVolumes.push(Volume.generateDockerName(vol.appId, vol.name)); referencedVolumes.push(
Volume.generateDockerName(parseInt(appId, 10), volumeName),
);
}); });
}); });
await volumeManager.removeOrphanedVolumes(referencedVolumes); await volumeManager.removeOrphanedVolumes(referencedVolumes);

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,7 @@ import { DeviceStatus } from '../types/state';
import { getRequestInstance } from '../lib/request'; import { getRequestInstance } from '../lib/request';
import * as config from '../config'; import * as config from '../config';
import * as eventTracker from '../event-tracker'; import * as eventTracker from '../event-tracker';
import DeviceState from '../device-state'; import * as deviceState from '../device-state';
import { CoreOptions } from 'request'; import { CoreOptions } from 'request';
import * as url from 'url'; import * as url from 'url';
@ -22,7 +22,6 @@ const INTERNAL_STATE_KEYS = [
'update_failed', 'update_failed',
]; ];
let deviceState: DeviceState;
export let stateReportErrors = 0; export let stateReportErrors = 0;
const lastReportedState: DeviceStatus = { const lastReportedState: DeviceStatus = {
local: {}, local: {},
@ -243,9 +242,7 @@ const reportCurrentState = (): null => {
return null; return null;
}; };
// TODO: Remove the passing in of deviceState once it's a singleton export const startReporting = () => {
export const startReporting = ($deviceState: typeof deviceState) => {
deviceState = $deviceState;
deviceState.on('change', () => { deviceState.on('change', () => {
if (!reportPending) { if (!reportPending) {
// A latency of 100ms should be acceptable and // A latency of 100ms should be acceptable and

View File

@ -1,26 +1,17 @@
import { promises as fs } from 'fs';
import * as path from 'path';
import * as _ from 'lodash'; import * as _ from 'lodash';
import type { ImageInspectInfo } from 'dockerode';
import * as config from '../config';
import * as db from '../db'; import * as db from '../db';
import * as targetStateCache from '../device-state/target-state-cache'; 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 App from '../compose/app';
import Network from '../compose/network';
import Volume from '../compose/volume';
import type {
DeviceMetadata,
ServiceComposeConfig,
} from '../compose/types/service';
import * as images from '../compose/images'; 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'; import { checkInt } from '../lib/validation';
type InstancedApp = InstancedAppState[0]; type InstancedApp = InstancedAppState[0];
@ -31,7 +22,7 @@ type InstancedApp = InstancedAppState[0];
// requiring that data here // requiring that data here
export async function getApp(id: number): Promise<InstancedApp> { export async function getApp(id: number): Promise<InstancedApp> {
const dbApp = await getDBEntry(id); const dbApp = await getDBEntry(id);
return await buildApp(dbApp); return await App.fromTargetState(dbApp);
} }
export async function getApps(): Promise<InstancedAppState> { export async function getApps(): Promise<InstancedAppState> {
@ -39,110 +30,12 @@ export async function getApps(): Promise<InstancedAppState> {
const apps: InstancedAppState = {}; const apps: InstancedAppState = {};
await Promise.all( await Promise.all(
dbApps.map(async (app) => { dbApps.map(async (app) => {
apps[app.appId] = await buildApp(app); apps[app.appId] = await App.fromTargetState(app);
}), }),
); );
return apps; 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( export async function setApps(
apps: { [appId: number]: TargetApplication }, apps: { [appId: number]: TargetApplication },
source: string, source: string,
@ -179,6 +72,36 @@ export async function setApps(
await targetStateCache.setTargetApps(dbApps, trx); 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(): Promise<targetStateCache.DatabaseApp[]>;
function getDBEntry(appId: number): Promise<targetStateCache.DatabaseApp>; function getDBEntry(appId: number): Promise<targetStateCache.DatabaseApp>;
async function getDBEntry(appId?: number) { async function getDBEntry(appId?: number) {

View File

@ -1,8 +1,8 @@
import * as _ from 'lodash'; import * as _ from 'lodash';
import { fs } from 'mz'; import { fs } from 'mz';
import { Image } from '../compose/images'; import { Image, imageFromService } from '../compose/images';
import DeviceState from '../device-state'; import * as deviceState from '../device-state';
import * as config from '../config'; import * as config from '../config';
import * as deviceConfig from '../device-config'; import * as deviceConfig from '../device-config';
import * as eventTracker from '../event-tracker'; import * as eventTracker from '../event-tracker';
@ -17,7 +17,6 @@ import { AppsJsonFormat } from '../types/state';
export async function loadTargetFromFile( export async function loadTargetFromFile(
appsPath: Nullable<string>, appsPath: Nullable<string>,
deviceState: DeviceState,
): Promise<void> { ): Promise<void> {
log.info('Attempting to load any preloaded applications'); log.info('Attempting to load any preloaded applications');
if (!appsPath) { if (!appsPath) {
@ -65,11 +64,11 @@ export async function loadTargetFromFile(
imageName: service.image, imageName: service.image,
serviceName: service.serviceName, serviceName: service.serviceName,
imageId: service.imageId, imageId: service.imageId,
serviceId, serviceId: parseInt(serviceId, 10),
releaseId: app.releaseId, releaseId: app.releaseId,
appId, appId: parseInt(appId, 10),
}; };
imgs.push(deviceState.applications.imageForService(svc)); imgs.push(imageFromService(svc));
} }
} }
@ -88,7 +87,6 @@ export async function loadTargetFromFile(
}; };
await deviceState.setTarget(localState); await deviceState.setTarget(localState);
log.success('Preloading complete'); log.success('Preloading complete');
if (preloadState.pinDevice) { if (preloadState.pinDevice) {
// Multi-app warning! // Multi-app warning!

213
src/lib/api-helper.ts Normal file
View File

@ -0,0 +1,213 @@
import { PinejsClientRequest } from 'pinejs-client-request';
import * as Bluebird from 'bluebird';
import * as config from '../config';
import * as eventTracker from '../event-tracker';
import * as request from './request';
import * as deviceRegister from './register-device';
import {
DeviceNotFoundError,
ExchangeKeyError,
FailedToProvisionDeviceError,
InternalInconsistencyError,
isHttpConflictError,
} from './errors';
import log from './supervisor-console';
export type KeyExchangeOpts = config.ConfigType<'provisioningOptions'>;
export interface Device {
id: number;
[key: string]: unknown;
}
export const fetchDevice = async (
balenaApi: PinejsClientRequest,
uuid: string,
apiKey: string,
timeout: number,
) => {
if (balenaApi == null) {
throw new InternalInconsistencyError(
'fetchDevice called without an initialized API client',
);
}
const reqOpts = {
resource: 'device',
options: {
$filter: {
uuid,
},
},
passthrough: {
headers: {
Authorization: `Bearer ${apiKey}`,
},
},
};
try {
const [device] = (await Bluebird.resolve(balenaApi.get(reqOpts)).timeout(
timeout,
)) as Device[];
if (device == null) {
throw new DeviceNotFoundError();
}
return device;
} catch (e) {
throw new DeviceNotFoundError();
}
};
export const exchangeKeyAndGetDeviceOrRegenerate = async (
balenaApi: PinejsClientRequest,
opts: KeyExchangeOpts,
): Promise<Device> => {
try {
const device = await exchangeKeyAndGetDevice(balenaApi, opts);
log.debug('Key exchange succeeded');
return device;
} catch (e) {
if (e instanceof ExchangeKeyError) {
log.error('Exchanging key failed, re-registering...');
await config.regenerateRegistrationFields();
}
throw e;
}
};
export const exchangeKeyAndGetDevice = async (
balenaApi: PinejsClientRequest,
opts: Partial<KeyExchangeOpts>,
): Promise<Device> => {
const uuid = opts.uuid;
const apiTimeout = opts.apiTimeout;
if (!(uuid && apiTimeout)) {
throw new InternalInconsistencyError(
'UUID and apiTimeout should be defined in exchangeKeyAndGetDevice',
);
}
// If we have an existing device key we first check if it's
// valid, because if it is then we can just use that
if (opts.deviceApiKey != null) {
try {
return await fetchDevice(balenaApi, uuid, opts.deviceApiKey, apiTimeout);
} catch (e) {
if (e instanceof DeviceNotFoundError) {
// do nothing...
} else {
throw e;
}
}
}
// If it's not valid or doesn't exist then we try to use the
// user/provisioning api key for the exchange
if (!opts.provisioningApiKey) {
throw new InternalInconsistencyError(
'Required a provisioning key in exchangeKeyAndGetDevice',
);
}
let device: Device;
try {
device = await fetchDevice(
balenaApi,
uuid,
opts.provisioningApiKey,
apiTimeout,
);
} catch (err) {
throw new ExchangeKeyError(`Couldn't fetch device with provisioning key`);
}
// We found the device so we can try to register a working device key for it
const [res] = await (await request.getRequestInstance())
.postAsync(`${opts.apiEndpoint}/api-key/device/${device.id}/device-key`, {
json: true,
body: {
apiKey: opts.deviceApiKey,
},
headers: {
Authorization: `Bearer ${opts.provisioningApiKey}`,
},
})
.timeout(apiTimeout);
if (res.statusCode !== 200) {
throw new ExchangeKeyError(
`Couldn't register device key with provisioning key`,
);
}
return device;
};
export const provision = async (
balenaApi: PinejsClientRequest,
opts: KeyExchangeOpts,
) => {
await config.initialized;
await eventTracker.initialized;
let device: Device | null = null;
if (
opts.registered_at == null ||
opts.deviceId == null ||
opts.provisioningApiKey != null
) {
if (opts.registered_at != null && opts.deviceId == null) {
log.debug(
'Device is registered but no device id available, attempting key exchange',
);
device = await exchangeKeyAndGetDeviceOrRegenerate(balenaApi, opts);
} else if (opts.registered_at == null) {
log.info('New device detected. Provisioning...');
try {
device = await deviceRegister.register(opts).timeout(opts.apiTimeout);
} catch (err) {
if (
err instanceof deviceRegister.ApiError &&
isHttpConflictError(err.response)
) {
log.debug('UUID already registered, trying a key exchange');
device = await exchangeKeyAndGetDeviceOrRegenerate(balenaApi, opts);
} else {
throw err;
}
}
opts.registered_at = Date.now();
} else if (opts.provisioningApiKey != null) {
log.debug(
'Device is registered but we still have an apiKey, attempting key exchange',
);
device = await exchangeKeyAndGetDevice(balenaApi, opts);
}
if (!device) {
throw new FailedToProvisionDeviceError();
}
const { id } = device;
balenaApi.passthrough.headers.Authorization = `Bearer ${opts.deviceApiKey}`;
const configToUpdate = {
registered_at: opts.registered_at,
deviceId: id,
apiKey: null,
};
await config.set(configToUpdate);
eventTracker.track('Device bootstrap success');
}
return device;
};

View File

@ -14,6 +14,8 @@ interface CodedSysError extends Error {
code?: string; code?: string;
} }
export class DeviceNotFoundError extends TypedError {}
export function NotFoundError(err: StatusCodeError): boolean { export function NotFoundError(err: StatusCodeError): boolean {
return checkInt(err.statusCode) === 404; return checkInt(err.statusCode) === 404;
} }
@ -50,6 +52,12 @@ export function isHttpConflictError(err: StatusCodeError | Response): boolean {
return checkInt(err.statusCode) === 409; return checkInt(err.statusCode) === 409;
} }
export class FailedToProvisionDeviceError extends TypedError {
public constructor() {
super('Failed to provision device');
}
}
export class ExchangeKeyError extends TypedError {} export class ExchangeKeyError extends TypedError {}
export class InternalInconsistencyError extends TypedError {} export class InternalInconsistencyError extends TypedError {}

View File

@ -3,20 +3,25 @@ import * as _ from 'lodash';
import * as mkdirp from 'mkdirp'; import * as mkdirp from 'mkdirp';
import { child_process, fs } from 'mz'; import { child_process, fs } from 'mz';
import * as path from 'path'; import * as path from 'path';
import { PinejsClientRequest } from 'pinejs-client-request';
import * as rimraf from 'rimraf'; import * as rimraf from 'rimraf';
const mkdirpAsync = Bluebird.promisify(mkdirp); const mkdirpAsync = Bluebird.promisify(mkdirp);
const rimrafAsync = Bluebird.promisify(rimraf); const rimrafAsync = Bluebird.promisify(rimraf);
import { ApplicationManager } from '../application-manager'; import * as apiBinder from '../api-binder';
import * as config from '../config'; import * as config from '../config';
import * as db from '../db'; import * as db from '../db';
import * as volumeManager from '../compose/volume-manager'; import * as volumeManager from '../compose/volume-manager';
import * as serviceManager from '../compose/service-manager'; import * as serviceManager from '../compose/service-manager';
import DeviceState from '../device-state'; import * as deviceState from '../device-state';
import * as applicationManager from '../compose/application-manager';
import * as constants from '../lib/constants'; import * as constants from '../lib/constants';
import { BackupError, DatabaseParseError, NotFoundError } from '../lib/errors'; import {
BackupError,
DatabaseParseError,
NotFoundError,
InternalInconsistencyError,
} from '../lib/errors';
import { docker } from '../lib/docker-utils'; import { docker } from '../lib/docker-utils';
import { pathExistsOnHost } from '../lib/fs-utils'; import { pathExistsOnHost } from '../lib/fs-utils';
import { log } from '../lib/supervisor-console'; import { log } from '../lib/supervisor-console';
@ -108,10 +113,16 @@ export function convertLegacyAppsJson(appsArray: any[]): AppsJsonFormat {
return { apps, config: deviceConfig } as AppsJsonFormat; return { apps, config: deviceConfig } as AppsJsonFormat;
} }
export async function normaliseLegacyDatabase( export async function normaliseLegacyDatabase() {
application: ApplicationManager, await apiBinder.initialized;
balenaApi: PinejsClientRequest, await deviceState.initialized;
) {
if (apiBinder.balenaApi == null) {
throw new InternalInconsistencyError(
'API binder is not initialized correctly',
);
}
// When legacy apps are present, we kill their containers and migrate their /data to a named volume // When legacy apps are present, we kill their containers and migrate their /data to a named volume
log.info('Migrating ids for legacy app...'); log.info('Migrating ids for legacy app...');
@ -147,7 +158,7 @@ export async function normaliseLegacyDatabase(
} }
log.debug(`Getting release ${app.commit} for app ${app.appId} from API`); log.debug(`Getting release ${app.commit} for app ${app.appId} from API`);
const releases = await balenaApi.get({ const releases = await apiBinder.balenaApi.get({
resource: 'release', resource: 'release',
options: { options: {
$filter: { $filter: {
@ -248,7 +259,8 @@ export async function normaliseLegacyDatabase(
await serviceManager.killAllLegacy(); await serviceManager.killAllLegacy();
log.debug('Migrating legacy app volumes'); log.debug('Migrating legacy app volumes');
const targetApps = await application.getTargetApps(); await applicationManager.initialized;
const targetApps = await applicationManager.getTargetApps();
for (const appId of _.keys(targetApps)) { for (const appId of _.keys(targetApps)) {
await volumeManager.createFromLegacy(parseInt(appId, 10)); await volumeManager.createFromLegacy(parseInt(appId, 10));
@ -260,7 +272,6 @@ export async function normaliseLegacyDatabase(
} }
export async function loadBackupFromMigration( export async function loadBackupFromMigration(
deviceState: DeviceState,
targetState: TargetState, targetState: TargetState,
retryDelay: number, retryDelay: number,
): Promise<void> { ): Promise<void> {
@ -340,6 +351,6 @@ export async function loadBackupFromMigration(
log.error(`Error restoring migration backup, retrying: ${err}`); log.error(`Error restoring migration backup, retrying: ${err}`);
await Bluebird.delay(retryDelay); await Bluebird.delay(retryDelay);
return loadBackupFromMigration(deviceState, targetState, retryDelay); return loadBackupFromMigration(targetState, retryDelay);
} }
} }

View File

@ -20,6 +20,12 @@ import * as db from './db';
import * as config from './config'; import * as config from './config';
import * as dockerUtils from './lib/docker-utils'; import * as dockerUtils from './lib/docker-utils';
import * as logger from './logger'; 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); const mkdirpAsync = Promise.promisify(mkdirp);
@ -116,7 +122,7 @@ const createProxyvisorRouter = function (proxyvisor) {
belongs_to__application: req.body.appId, belongs_to__application: req.body.appId,
device_type, device_type,
}; };
return proxyvisor.apiBinder return apiBinder
.provisionDependentDevice(d) .provisionDependentDevice(d)
.then(function (dev) { .then(function (dev) {
// If the response has id: null then something was wrong in the request // If the response has id: null then something was wrong in the request
@ -277,10 +283,7 @@ const createProxyvisorRouter = function (proxyvisor) {
} }
return Promise.try(function () { return Promise.try(function () {
if (!_.isEmpty(fieldsToUpdateOnAPI)) { if (!_.isEmpty(fieldsToUpdateOnAPI)) {
return proxyvisor.apiBinder.patchDevice( return apiBinder.patchDevice(device.deviceId, fieldsToUpdateOnAPI);
device.deviceId,
fieldsToUpdateOnAPI,
);
} }
}) })
.then(() => .then(() =>
@ -347,8 +350,7 @@ const createProxyvisorRouter = function (proxyvisor) {
}; };
export class Proxyvisor { export class Proxyvisor {
constructor({ applications }) { constructor() {
this.bindToAPI = this.bindToAPI.bind(this);
this.executeStepAction = this.executeStepAction.bind(this); this.executeStepAction = this.executeStepAction.bind(this);
this.getCurrentStates = this.getCurrentStates.bind(this); this.getCurrentStates = this.getCurrentStates.bind(this);
this.normaliseDependentAppForDB = this.normaliseDependentAppForDB.bind( this.normaliseDependentAppForDB = this.normaliseDependentAppForDB.bind(
@ -363,14 +365,13 @@ export class Proxyvisor {
this.sendUpdate = this.sendUpdate.bind(this); this.sendUpdate = this.sendUpdate.bind(this);
this.sendDeleteHook = this.sendDeleteHook.bind(this); this.sendDeleteHook = this.sendDeleteHook.bind(this);
this.sendUpdates = this.sendUpdates.bind(this); this.sendUpdates = this.sendUpdates.bind(this);
this.applications = applications;
this.acknowledgedState = {}; this.acknowledgedState = {};
this.lastRequestForDevice = {}; this.lastRequestForDevice = {};
this.router = createProxyvisorRouter(this); this.router = createProxyvisorRouter(this);
this.actionExecutors = { this.actionExecutors = {
updateDependentTargets: (step) => { updateDependentTargets: (step) => {
return config return config.initialized
.getMany(['currentApiKey', 'apiTimeout']) .then(() => config.getMany(['currentApiKey', 'apiTimeout']))
.then(({ currentApiKey, apiTimeout }) => { .then(({ currentApiKey, apiTimeout }) => {
// - take each of the step.devices and update dependentDevice with it (targetCommit, targetEnvironment, targetConfig) // - 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 // - if update returns 0, then use APIBinder to fetch the device, then store it to the db
@ -406,9 +407,25 @@ export class Proxyvisor {
} }
// If the device is not in the DB it means it was provisioned externally // If the device is not in the DB it means it was provisioned externally
// so we need to fetch it. // so we need to fetch it.
return this.apiBinder if (apiBinder.balenaApi == null) {
.fetchDevice(uuid, currentApiKey, apiTimeout) throw new InternalInconsistencyError(
'proxyvisor called fetchDevice without an initialized API client',
);
}
return apiHelper
.fetchDevice(
apiBinder.balenaApi,
uuid,
currentApiKey,
apiTimeout,
)
.then((dev) => { .then((dev) => {
if (dev == null) {
throw new InternalInconsistencyError(
`Could not fetch a device with UUID: ${uuid}`,
);
}
const deviceForDB = { const deviceForDB = {
uuid, uuid,
appId, appId,
@ -488,10 +505,6 @@ export class Proxyvisor {
this.validActions = _.keys(this.actionExecutors); this.validActions = _.keys(this.actionExecutors);
} }
bindToAPI(apiBinder) {
return (this.apiBinder = apiBinder);
}
executeStepAction(step) { executeStepAction(step) {
return Promise.try(() => { return Promise.try(() => {
if (this.actionExecutors[step.action] == null) { if (this.actionExecutors[step.action] == null) {
@ -694,15 +707,15 @@ export class Proxyvisor {
imagesInUse(current, target) { imagesInUse(current, target) {
const images = []; const images = [];
if (current.dependent?.apps != null) { if (current?.dependent?.apps != null) {
for (const app of current.dependent.apps) { _.forEach(current.dependent.apps, (app) => {
images.push(app.image); images.push(app.image);
} });
} }
if (target?.dependent.apps != null) { if (target?.dependent?.apps != null) {
for (const app of target.dependent.apps) { _.forEach(target.dependent.apps, (app) => {
images.push(app.image); images.push(app.image);
} });
} }
return images; return images;
} }
@ -899,12 +912,10 @@ export class Proxyvisor {
.models('dependentApp') .models('dependentApp')
.select('parentApp') .select('parentApp')
.where({ appId }) .where({ appId })
.then(([{ parentApp }]) => { .then(([{ parentApp }]) => dbFormat.getApp(parseInt(parentApp, 10)))
return this.applications.getTargetApp(parentApp);
})
.then((parentApp) => { .then((parentApp) => {
return Promise.map(parentApp?.services ?? [], (service) => { return Promise.map(parentApp.services ?? [], (service) => {
return dockerUtils.getImageEnv(service.image); return dockerUtils.getImageEnv(service.config.image);
}).then(function (imageEnvs) { }).then(function (imageEnvs) {
const imageHookAddresses = _.map( const imageHookAddresses = _.map(
imageEnvs, imageEnvs,
@ -917,11 +928,16 @@ export class Proxyvisor {
return addr; return addr;
} }
} }
return ( // If we don't find the hook address in the images, we take it from
parentApp?.config?.BALENA_DEPENDENT_DEVICES_HOOK_ADDRESS ?? // the global config
parentApp?.config?.RESIN_DEPENDENT_DEVICES_HOOK_ADDRESS ?? return deviceConfig
`${constants.proxyvisorHookReceiver}/v1/devices/` .getTarget()
); .then(
(target) =>
target.BALENA_DEPENDENT_DEVICES_HOOK_ADDRESS ??
target.RESIN_DEPENDENT_DEVICES_HOOK_ADDRESS ??
`${constants.proxyvisorHookReceiver}/v1/devices/`,
);
}); });
}); });
} }

View File

@ -1,7 +1,7 @@
import APIBinder from './api-binder'; import * as apiBinder from './api-binder';
import * as db from './db'; import * as db from './db';
import * as config from './config'; import * as config from './config';
import DeviceState from './device-state'; import * as deviceState from './device-state';
import * as eventTracker from './event-tracker'; import * as eventTracker from './event-tracker';
import { intialiseContractRequirements } from './lib/contracts'; import { intialiseContractRequirements } from './lib/contracts';
import { normaliseLegacyDatabase } from './lib/migration'; import { normaliseLegacyDatabase } from './lib/migration';
@ -31,31 +31,8 @@ const startupConfigFields: config.ConfigKey[] = [
]; ];
export class Supervisor { export class Supervisor {
private deviceState: DeviceState;
private apiBinder: APIBinder;
private api: SupervisorAPI; private api: SupervisorAPI;
public constructor() {
this.apiBinder = new APIBinder();
this.deviceState = new DeviceState({
apiBinder: this.apiBinder,
});
// workaround the circular dependency
this.apiBinder.setDeviceState(this.deviceState);
// FIXME: rearchitect proxyvisor to avoid this circular dependency
// by storing current state and having the APIBinder query and report it / provision devices
this.deviceState.applications.proxyvisor.bindToAPI(this.apiBinder);
this.api = new SupervisorAPI({
routers: [this.apiBinder.router, this.deviceState.router],
healthchecks: [
this.apiBinder.healthcheck.bind(this.apiBinder),
this.deviceState.healthcheck.bind(this.deviceState),
],
});
}
public async init() { public async init() {
log.info(`Supervisor v${version} starting up...`); log.info(`Supervisor v${version} starting up...`);
@ -78,24 +55,27 @@ export class Supervisor {
await firewall.initialised; await firewall.initialised;
log.debug('Starting api binder'); log.debug('Starting api binder');
await this.apiBinder.initClient(); await apiBinder.initialized;
await deviceState.initialized;
logger.logSystemMessage('Supervisor starting', {}, 'Supervisor start'); logger.logSystemMessage('Supervisor starting', {}, 'Supervisor start');
if (conf.legacyAppsPresent && this.apiBinder.balenaApi != null) { if (conf.legacyAppsPresent && apiBinder.balenaApi != null) {
log.info('Legacy app detected, running migration'); log.info('Legacy app detected, running migration');
await normaliseLegacyDatabase( await normaliseLegacyDatabase();
this.deviceState.applications,
this.apiBinder.balenaApi,
);
} }
await this.deviceState.init(); await deviceState.loadInitialState();
log.info('Starting API server'); 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); this.api.listen(conf.listenPort, conf.apiTimeout);
this.deviceState.on('shutdown', () => this.api.stop()); deviceState.on('shutdown', () => this.api.stop());
await this.apiBinder.start(); await apiBinder.start();
} }
} }

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 { ComposeNetworkConfig } from '../compose/types/network';
import { ServiceComposeConfig } from '../compose/types/service'; import { ServiceComposeConfig } from '../compose/types/service';
import Volume, { ComposeVolumeConfig } from '../compose/volume'; import { ComposeVolumeConfig } from '../compose/volume';
import { EnvVarObject, LabelObject } from '../lib/types'; import { EnvVarObject, LabelObject } from '../lib/types';
import Network from '../compose/network'; import App from '../compose/app';
import Service from '../compose/service';
export type DeviceReportFields = Partial<{ export type DeviceReportFields = Partial<{
api_port: number; api_port: number;
@ -24,6 +23,7 @@ export type DeviceReportFields = Partial<{
mac_address: string | null; mac_address: string | null;
}>; }>;
// This is the state that is sent to the cloud
export interface DeviceStatus { export interface DeviceStatus {
local?: { local?: {
config?: Dictionary<string>; config?: Dictionary<string>;
@ -93,26 +93,12 @@ export interface TargetState {
export type LocalTargetState = TargetState['local']; export type LocalTargetState = TargetState['local'];
export type TargetApplications = LocalTargetState['apps']; export type TargetApplications = LocalTargetState['apps'];
export type TargetApplication = LocalTargetState['apps'][0]; export type TargetApplication = LocalTargetState['apps'][0];
export type TargetApplicationService = TargetApplication['services'][0];
export type AppsJsonFormat = Omit<TargetState['local'], 'name'> & { export type AppsJsonFormat = Omit<TargetState['local'], 'name'> & {
pinDevice?: boolean; pinDevice?: boolean;
}; };
// This structure is the internal representation of both export type InstancedAppState = { [appId: number]: App };
// 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 interface InstancedDeviceState { export interface InstancedDeviceState {
local: { local: {

View File

@ -6,8 +6,6 @@ process.env.DATABASE_PATH_2 = './test/data/database2.sqlite';
process.env.DATABASE_PATH_3 = './test/data/database3.sqlite'; process.env.DATABASE_PATH_3 = './test/data/database3.sqlite';
process.env.LED_FILE = './test/data/led_file'; process.env.LED_FILE = './test/data/led_file';
import './lib/mocked-iptables';
import * as dbus from 'dbus'; import * as dbus from 'dbus';
import { DBusError, DBusInterface } from 'dbus'; import { DBusError, DBusInterface } from 'dbus';
import { stub } from 'sinon'; import { stub } from 'sinon';
@ -59,3 +57,7 @@ stub(dbus, 'getBus').returns({
} as any); } as any);
}, },
} as any); } as any);
import './lib/mocked-dockerode';
import './lib/mocked-iptables';
import './lib/mocked-event-tracker';

View File

@ -122,6 +122,11 @@ describe('Config', () => {
expect(osVariant).to.be.undefined; expect(osVariant).to.be.undefined;
}); });
it('reads and exposes MAC addresses', async () => {
const macAddress = await conf.get('macAddress');
expect(macAddress).to.have.length.greaterThan(0);
});
describe('Function config providers', () => { describe('Function config providers', () => {
it('should throw if a non-mutable function provider is set', () => { it('should throw if a non-mutable function provider is set', () => {
expect(conf.set({ version: 'some-version' })).to.be.rejected; expect(conf.set({ version: 'some-version' })).to.be.rejected;

View File

@ -518,7 +518,7 @@ describe('compose/service', () => {
expect(dockerSvc.isEqualConfig(composeSvc, {})).to.be.true; 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( const composeSvc = Service.fromComposeObject(
configs.entrypoint.compose, configs.entrypoint.compose,
configs.entrypoint.imageInfo, configs.entrypoint.imageInfo,

View File

@ -1,17 +1,14 @@
import * as Bluebird from 'bluebird';
import { stripIndent } from 'common-tags';
import * as _ from 'lodash'; import * as _ from 'lodash';
import { SinonSpy, SinonStub, spy, stub } from 'sinon'; import { stub } from 'sinon';
import chai = require('./lib/chai-config'); import chai = require('./lib/chai-config');
import { StatusCodeError } from '../src/lib/errors'; import { StatusCodeError } from '../src/lib/errors';
import prepare = require('./lib/prepare'); import prepare = require('./lib/prepare');
import Log from '../src/lib/supervisor-console';
import * as dockerUtils from '../src/lib/docker-utils'; import * as dockerUtils from '../src/lib/docker-utils';
import * as config from '../src/config'; import * as config from '../src/config';
import * as images from '../src/compose/images'; import * as images from '../src/compose/images';
import { ConfigTxt } from '../src/config/backends/config-txt'; import { ConfigTxt } from '../src/config/backends/config-txt';
import DeviceState from '../src/device-state'; import * as deviceState from '../src/device-state';
import * as deviceConfig from '../src/device-config'; import * as deviceConfig from '../src/device-config';
import { loadTargetFromFile } from '../src/device-state/preload'; import { loadTargetFromFile } from '../src/device-state/preload';
import Service from '../src/compose/service'; import Service from '../src/compose/service';
@ -37,55 +34,6 @@ const mockedInitialConfig = {
RESIN_SUPERVISOR_VPN_CONTROL: 'true', 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 = { const testTarget2 = {
local: { local: {
name: 'aDeviceWithDifferentName', name: 'aDeviceWithDifferentName',
@ -215,14 +163,16 @@ const testTargetInvalid = {
}; };
describe('deviceState', () => { describe('deviceState', () => {
let deviceState: DeviceState;
let source: string; let source: string;
const originalImagesSave = images.save; const originalImagesSave = images.save;
const originalImagesInspect = images.inspectByName; const originalImagesInspect = images.inspectByName;
const originalGetCurrent = deviceConfig.getCurrent; const originalGetCurrent = deviceConfig.getCurrent;
before(async () => { before(async () => {
await prepare(); await prepare();
await config.initialized; await config.initialized;
await deviceState.initialized;
source = await config.get('apiEndpoint'); source = await config.get('apiEndpoint');
stub(Service as any, 'extendEnvVars').callsFake((env) => { stub(Service as any, 'extendEnvVars').callsFake((env) => {
@ -235,14 +185,15 @@ describe('deviceState', () => {
deviceType: 'intel-nuc', deviceType: 'intel-nuc',
}); });
deviceState = new DeviceState({
apiBinder: null as any,
});
stub(dockerUtils, 'getNetworkGateway').returns( stub(dockerUtils, 'getNetworkGateway').returns(
Promise.resolve('172.17.0.1'), 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 // @ts-expect-error Assigning to a RO property
images.save = () => Promise.resolve(); images.save = () => Promise.resolve();
@ -277,47 +228,83 @@ describe('deviceState', () => {
}); });
it('loads a target state from an apps.json file and saves it as target state, then returns it', async () => { it('loads a target state from an apps.json file and saves it as target state, then returns it', async () => {
await loadTargetFromFile( await loadTargetFromFile(process.env.ROOT_MOUNTPOINT + '/apps.json');
process.env.ROOT_MOUNTPOINT + '/apps.json',
deviceState,
);
const targetState = await deviceState.getTarget(); const targetState = await deviceState.getTarget();
const testTarget = _.cloneDeep(testTarget1); expect(targetState)
testTarget.local.apps['1234'].services = _.mapValues( .to.have.property('local')
testTarget.local.apps['1234'].services, .that.has.property('apps')
(s: any) => { .that.has.property('1234')
s.imageName = s.image; .that.is.an('object');
return Service.fromComposeObject(s, { appName: 'superapp' } as any); const app = targetState.local.apps[1234];
}, expect(app).to.have.property('appName').that.equals('superapp');
) as any; expect(app).to.have.property('services').that.is.an('array').with.length(1);
// @ts-ignore expect(app.services[0])
testTarget.local.apps['1234'].source = source; .to.have.property('config')
.that.has.property('image')
expect(JSON.parse(JSON.stringify(targetState))).to.deep.equal( .that.equals('registry2.resin.io/superapp/abcdef:latest');
JSON.parse(JSON.stringify(testTarget)), 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 () => { it('stores info for pinning a device after loading an apps.json with a pinDevice field', async () => {
await loadTargetFromFile( await loadTargetFromFile(process.env.ROOT_MOUNTPOINT + '/apps-pin.json');
process.env.ROOT_MOUNTPOINT + '/apps-pin.json',
deviceState,
);
const pinned = await config.get('pinDevice'); const pinned = await config.get('pinDevice');
expect(pinned).to.have.property('app').that.equals(1234); expect(pinned).to.have.property('app').that.equals(1234);
expect(pinned).to.have.property('commit').that.equals('abcdef'); expect(pinned).to.have.property('commit').that.equals('abcdef');
}); });
it('emits a change event when a new state is reported', () => { it('emits a change event when a new state is reported', (done) => {
deviceState.once('change', done);
deviceState.reportCurrentState({ someStateDiff: 'someValue' } as any); deviceState.reportCurrentState({ someStateDiff: 'someValue' } as any);
return (expect as any)(deviceState).to.emit('change');
}); });
it('returns the current state'); 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 testTarget = _.cloneDeep(testTargetWithDefaults2);
const services: Service[] = []; const services: Service[] = [];
@ -347,105 +334,30 @@ describe('deviceState', () => {
}); });
it('allows triggering applying the target state', (done) => { it('allows triggering applying the target state', (done) => {
stub(deviceState as any, 'applyTarget').returns(Promise.resolve()); const applyTargetStub = stub(deviceState, 'applyTarget').returns(
Promise.resolve(),
);
deviceState.triggerApplyTarget({ force: true }); deviceState.triggerApplyTarget({ force: true });
expect((deviceState as any).applyTarget).to.not.be.called; expect(applyTargetStub).to.not.be.called;
setTimeout(() => { setTimeout(() => {
expect((deviceState as any).applyTarget).to.be.calledWith({ expect(applyTargetStub).to.be.calledWith({
force: true, force: true,
initial: false, initial: false,
}); });
(deviceState as any).applyTarget.restore(); applyTargetStub.restore();
done(); done();
}, 5); }, 1000);
}); });
it('cancels current promise applying the target state', (done) => { // TODO: There is no easy way to test this behaviour with the current
(deviceState as any).scheduledApply = { force: false, delay: 100 }; // interface of device-state. We should really think about the device-state
(deviceState as any).applyInProgress = true; // interface to allow this flexibility (and to avoid having to change module
(deviceState as any).applyCancelled = false; // internal variables)
it.skip('cancels current promise applying the target state');
new Bluebird((resolve, reject) => { it.skip('applies the target state for device config');
setTimeout(resolve, 100000);
(deviceState as any).cancelDelay = reject;
})
.catch(() => {
(deviceState as any).applyCancelled = true;
})
.finally(() => {
expect((deviceState as any).scheduledApply).to.deep.equal({
force: true,
delay: 0,
});
expect((deviceState as any).applyCancelled).to.be.true;
done();
});
deviceState.triggerApplyTarget({ force: true, isFromApi: true }); it.skip('applies the target state for applications');
});
it('applies the target state for device config');
it('applies the target state for applications');
describe('healthchecks', () => {
let configStub: SinonStub;
let infoLobSpy: SinonSpy;
beforeEach(() => {
// This configStub will be modified in each test case so we can
// create the exact conditions we want to for testing healthchecks
configStub = stub(config, 'get');
infoLobSpy = spy(Log, 'info');
});
afterEach(() => {
configStub.restore();
infoLobSpy.restore();
});
it('passes with correct conditions', async () => {
// Setup passing condition
const previousValue = deviceState.applyInProgress;
deviceState.applyInProgress = false;
expect(await deviceState.healthcheck()).to.equal(true);
// Restore value
deviceState.applyInProgress = previousValue;
});
it('passes if unmanaged is true and exit early', async () => {
// Setup failing conditions
const previousValue = deviceState.applyInProgress;
deviceState.applyInProgress = true;
// Verify this causes healthcheck to fail
expect(await deviceState.healthcheck()).to.equal(false);
// Do it again but set unmanaged to true
configStub.resolves({
unmanaged: true,
});
expect(await deviceState.healthcheck()).to.equal(true);
// Restore value
deviceState.applyInProgress = previousValue;
});
it('fails when applyTargetHealthy is false', async () => {
// Copy previous values to restore later
const previousValue = deviceState.applyInProgress;
// Setup failing conditions
deviceState.applyInProgress = true;
expect(await deviceState.healthcheck()).to.equal(false);
expect(Log.info).to.be.calledOnce;
expect((Log.info as SinonSpy).lastCall?.lastArg).to.equal(
stripIndent`
Healthcheck failure - At least ONE of the following conditions must be true:
- No applyInProgress ? false
- fetchesInProgress ? false
- cycleTimeWithinInterval ? false`,
);
// Restore value
deviceState.applyInProgress = previousValue;
});
});
}); });

View File

@ -3,11 +3,9 @@ import { fs } from 'mz';
import { Server } from 'net'; import { Server } from 'net';
import { SinonSpy, SinonStub, spy, stub } from 'sinon'; import { SinonSpy, SinonStub, spy, stub } from 'sinon';
import ApiBinder from '../src/api-binder';
import prepare = require('./lib/prepare'); import prepare = require('./lib/prepare');
import * as config from '../src/config'; import * as config from '../src/config';
import DeviceState from '../src/device-state'; import * as deviceState from '../src/device-state';
import * as eventTracker from '../src/event-tracker';
import Log from '../src/lib/supervisor-console'; import Log from '../src/lib/supervisor-console';
import chai = require('./lib/chai-config'); import chai = require('./lib/chai-config');
import balenaAPI = require('./lib/mocked-balena-api'); import balenaAPI = require('./lib/mocked-balena-api');
@ -16,15 +14,25 @@ import ConfigJsonConfigBackend from '../src/config/configJson';
import * as TargetState from '../src/device-state/target-state'; import * as TargetState from '../src/device-state/target-state';
import { DeviceStatus } from '../src/types/state'; import { DeviceStatus } from '../src/types/state';
import * as CurrentState from '../src/device-state/current-state'; import * as CurrentState from '../src/device-state/current-state';
import * as ApiHelper from '../src/lib/api-helper';
import { TypedError } from 'typed-error';
import { DeviceNotFoundError } from '../src/lib/errors';
import { eventTrackSpy } from './lib/mocked-event-tracker';
const { expect } = chai; const { expect } = chai;
let ApiBinder: typeof import('../src/api-binder');
class ExpectedError extends TypedError {}
const defaultConfigBackend = config.configJsonBackend;
const initModels = async (obj: Dictionary<any>, filename: string) => { const initModels = async (obj: Dictionary<any>, filename: string) => {
await prepare(); await prepare();
// @ts-expect-error setting read-only property // @ts-expect-error setting read-only property
config.configJsonBackend = new ConfigJsonConfigBackend(schema, filename); config.configJsonBackend = new ConfigJsonConfigBackend(schema, filename);
config.generateRequiredFields(); await config.generateRequiredFields();
// @ts-expect-error using private properties // @ts-expect-error using private properties
config.configJsonBackend.cache = await config.configJsonBackend.read(); config.configJsonBackend.cache = await config.configJsonBackend.read();
await config.generateRequiredFields(); await config.generateRequiredFields();
@ -35,15 +43,12 @@ const initModels = async (obj: Dictionary<any>, filename: string) => {
}, },
} as any; } as any;
obj.apiBinder = new ApiBinder(); ApiBinder = await import('../src/api-binder');
await ApiBinder.initialized;
obj.apiBinder = ApiBinder;
obj.deviceState = new DeviceState({ await deviceState.initialized;
apiBinder: obj.apiBinder, obj.deviceState = deviceState;
});
obj.apiBinder.setDeviceState(obj.deviceState);
await obj.apiBinder.initClient(); // Initializes the clients but doesn't trigger provisioning
}; };
const mockProvisioningOpts = { const mockProvisioningOpts = {
@ -55,17 +60,12 @@ const mockProvisioningOpts = {
}; };
describe('ApiBinder', () => { describe('ApiBinder', () => {
const defaultConfigBackend = config.configJsonBackend;
let server: Server; let server: Server;
beforeEach(() => { before(async () => {
stub(eventTracker, 'track'); delete require.cache[require.resolve('../src/api-binder')];
});
afterEach(() => {
// @ts-expect-error Restoring a non-stub type function
eventTracker.track.restore();
});
before(() => {
spy(balenaAPI.balenaBackend!, 'registerHandler'); spy(balenaAPI.balenaBackend!, 'registerHandler');
server = balenaAPI.listen(3000); server = balenaAPI.listen(3000);
}); });
@ -83,31 +83,39 @@ describe('ApiBinder', () => {
// We do not support older OS versions anymore, so we only test this case // We do not support older OS versions anymore, so we only test this case
describe('on an OS with deviceApiKey support', () => { describe('on an OS with deviceApiKey support', () => {
const components: Dictionary<any> = {}; const components: Dictionary<any> = {};
before(() => {
return initModels(components, '/config-apibinder.json'); before(async () => {
await initModels(components, '/config-apibinder.json');
});
afterEach(() => {
eventTrackSpy.resetHistory();
}); });
after(async () => { after(async () => {
eventTrackSpy.restore();
// @ts-expect-error setting read-only property // @ts-expect-error setting read-only property
config.configJsonBackend = defaultConfigBackend; config.configJsonBackend = defaultConfigBackend;
await config.generateRequiredFields(); await config.generateRequiredFields();
}); });
it('provisions a device', () => { it('provisions a device', async () => {
const promise = components.apiBinder.provisionDevice(); const opts = await config.get('provisioningOptions');
await ApiHelper.provision(components.apiBinder.balenaApi, opts);
return expect(promise).to.be.fulfilled.then(() => { expect(balenaAPI.balenaBackend!.registerHandler).to.be.calledOnce;
expect(balenaAPI.balenaBackend!.registerHandler).to.be.calledOnce; expect(eventTrackSpy).to.be.called;
expect(eventTrackSpy).to.be.calledWith('Device bootstrap success');
// @ts-expect-error function does not exist on type // @ts-expect-error function does not exist on type
balenaAPI.balenaBackend!.registerHandler.resetHistory(); balenaAPI.balenaBackend!.registerHandler.resetHistory();
expect(eventTracker.track).to.be.calledWith('Device bootstrap success');
});
}); });
it('exchanges keys if resource conflict when provisioning', async () => { it('exchanges keys if resource conflict when provisioning', async () => {
// Get current config to extend // Get current config to extend
const currentConfig = await config.get('provisioningOptions'); const currentConfig = await config.get('provisioningOptions');
// Stub config values so we have correct conditions // Stub config values so we have correct conditions
const configStub = stub(config, 'get').resolves({ const configStub = stub(config, 'get').resolves({
...currentConfig, ...currentConfig,
@ -115,30 +123,32 @@ describe('ApiBinder', () => {
provisioningApiKey: '123', // Previous test case deleted the provisioningApiKey so add one provisioningApiKey: '123', // Previous test case deleted the provisioningApiKey so add one
uuid: 'not-unique', // This UUID is used in mocked-balena-api as an existing registered UUID uuid: 'not-unique', // This UUID is used in mocked-balena-api as an existing registered UUID
}); });
// If api-binder reaches this function then tests pass // If api-binder reaches this function then tests pass
// We throw an error so we don't have to keep stubbing
const functionToReach = stub( const functionToReach = stub(
components.apiBinder, ApiHelper,
'exchangeKeyAndGetDeviceOrRegenerate', 'exchangeKeyAndGetDeviceOrRegenerate',
).rejects('expected-rejection'); // We throw an error so we don't have to keep stubbing ).throws(new ExpectedError());
spy(Log, 'debug'); spy(Log, 'debug');
try { try {
await components.apiBinder.provision(); const opts = await config.get('provisioningOptions');
await ApiHelper.provision(components.apiBinder.balenaApi, opts);
} catch (e) { } catch (e) {
// Check that the error thrown is from this test // Check that the error thrown is from this test
if (e.name !== 'expected-rejection') { expect(e).to.be.instanceOf(ExpectedError);
throw e;
}
} }
expect(functionToReach).to.be.calledOnce; expect(functionToReach.called).to.be.true;
expect((Log.debug as SinonSpy).lastCall.lastArg).to.equal( expect((Log.debug as SinonSpy).lastCall.lastArg).to.equal(
'UUID already registered, trying a key exchange', 'UUID already registered, trying a key exchange',
); );
// Restore stubs // Restore stubs
functionToReach.restore();
configStub.restore(); configStub.restore();
functionToReach.restore();
(Log.debug as SinonStub).restore(); (Log.debug as SinonStub).restore();
}); });
@ -184,7 +194,8 @@ describe('ApiBinder', () => {
api_key: 'verysecure', api_key: 'verysecure',
}; };
const device = await components.apiBinder.fetchDevice( const device = await ApiHelper.fetchDevice(
components.apiBinder.balenaApi,
'abcd', 'abcd',
'someApiKey', 'someApiKey',
30000, 30000,
@ -207,27 +218,31 @@ describe('ApiBinder', () => {
it('returns the device if it can fetch it with the deviceApiKey', async () => { it('returns the device if it can fetch it with the deviceApiKey', async () => {
spy(balenaAPI.balenaBackend!, 'deviceKeyHandler'); spy(balenaAPI.balenaBackend!, 'deviceKeyHandler');
const fetchDeviceStub = stub(components.apiBinder, 'fetchDevice'); const fetchDeviceStub = stub(ApiHelper, 'fetchDevice');
fetchDeviceStub.onCall(0).resolves({ id: 1 }); fetchDeviceStub.onCall(0).resolves({ id: 1 });
const device = await components.apiBinder.exchangeKeyAndGetDevice( const device = await ApiHelper.exchangeKeyAndGetDevice(
components.apiBinder.balenaApi,
mockProvisioningOpts, mockProvisioningOpts,
); );
expect(balenaAPI.balenaBackend!.deviceKeyHandler).to.not.be.called; expect(balenaAPI.balenaBackend!.deviceKeyHandler).to.not.be.called;
expect(device).to.deep.equal({ id: 1 }); expect(device).to.deep.equal({ id: 1 });
expect(components.apiBinder.fetchDevice).to.be.calledOnce; expect(fetchDeviceStub).to.be.calledOnce;
components.apiBinder.fetchDevice.restore();
// @ts-expect-error function does not exist on type // @ts-expect-error function does not exist on type
balenaAPI.balenaBackend.deviceKeyHandler.restore(); balenaAPI.balenaBackend.deviceKeyHandler.restore();
fetchDeviceStub.restore();
}); });
it('throws if it cannot get the device with any of the keys', () => { it('throws if it cannot get the device with any of the keys', () => {
spy(balenaAPI.balenaBackend!, 'deviceKeyHandler'); spy(balenaAPI.balenaBackend!, 'deviceKeyHandler');
stub(components.apiBinder, 'fetchDevice').returns(Promise.resolve(null)); const fetchDeviceStub = stub(ApiHelper, 'fetchDevice').throws(
new DeviceNotFoundError(),
);
const promise = components.apiBinder.exchangeKeyAndGetDevice( const promise = ApiHelper.exchangeKeyAndGetDevice(
components.apiBinder.balenaApi,
mockProvisioningOpts, mockProvisioningOpts,
); );
promise.catch(() => { promise.catch(() => {
@ -236,8 +251,8 @@ describe('ApiBinder', () => {
return expect(promise).to.be.rejected.then(() => { return expect(promise).to.be.rejected.then(() => {
expect(balenaAPI.balenaBackend!.deviceKeyHandler).to.not.be.called; expect(balenaAPI.balenaBackend!.deviceKeyHandler).to.not.be.called;
expect(components.apiBinder.fetchDevice).to.be.calledTwice; expect(fetchDeviceStub).to.be.calledTwice;
components.apiBinder.fetchDevice.restore(); fetchDeviceStub.restore();
// @ts-expect-error function does not exist on type // @ts-expect-error function does not exist on type
balenaAPI.balenaBackend.deviceKeyHandler.restore(); balenaAPI.balenaBackend.deviceKeyHandler.restore();
}); });
@ -245,17 +260,18 @@ describe('ApiBinder', () => {
it('exchanges the key and returns the device if the provisioning key is valid', async () => { it('exchanges the key and returns the device if the provisioning key is valid', async () => {
spy(balenaAPI.balenaBackend!, 'deviceKeyHandler'); spy(balenaAPI.balenaBackend!, 'deviceKeyHandler');
const fetchDeviceStub = stub(components.apiBinder, 'fetchDevice'); const fetchDeviceStub = stub(ApiHelper, 'fetchDevice');
fetchDeviceStub.onCall(0).returns(Promise.resolve(null)); fetchDeviceStub.onCall(0).throws(new DeviceNotFoundError());
fetchDeviceStub.onCall(1).returns(Promise.resolve({ id: 1 })); fetchDeviceStub.onCall(1).returns(Promise.resolve({ id: 1 }));
const device = await components.apiBinder.exchangeKeyAndGetDevice( const device = await ApiHelper.exchangeKeyAndGetDevice(
components.apiBinder.balenaApi,
mockProvisioningOpts as any, mockProvisioningOpts as any,
); );
expect(balenaAPI.balenaBackend!.deviceKeyHandler).to.be.calledOnce; expect(balenaAPI.balenaBackend!.deviceKeyHandler).to.be.calledOnce;
expect(device).to.deep.equal({ id: 1 }); expect(device).to.deep.equal({ id: 1 });
expect(components.apiBinder.fetchDevice).to.be.calledTwice; expect(fetchDeviceStub).to.be.calledTwice;
components.apiBinder.fetchDevice.restore(); fetchDeviceStub.restore();
// @ts-expect-error function does not exist on type // @ts-expect-error function does not exist on type
balenaAPI.balenaBackend.deviceKeyHandler.restore(); balenaAPI.balenaBackend.deviceKeyHandler.restore();
}); });
@ -436,17 +452,22 @@ describe('ApiBinder', () => {
appUpdatePollInterval: 1000, appUpdatePollInterval: 1000,
connectivityCheckEnabled: true, connectivityCheckEnabled: true,
}); });
// Set lastFetch to now so it is within appUpdatePollInterval // Set lastFetch to now so it is within appUpdatePollInterval
(TargetState as any).lastFetch = process.hrtime(); (TargetState as any).lastFetch = process.hrtime();
// Copy previous values to restore later // Copy previous values to restore later
const previousStateReportErrors = components.apiBinder.stateReportErrors; const previousStateReportErrors = components.apiBinder.stateReportErrors;
const previousDeviceStateConnected = const previousDeviceStateConnected =
components.apiBinder.deviceState.connected; // @ts-ignore
components.deviceState.connected;
// Set additional conditions not in configStub to cause a fail // Set additional conditions not in configStub to cause a fail
// @ts-expect-error components.apiBinder.stateReportErrors = 4;
CurrentState.stateReportErrors = 4; components.deviceState.connected = true;
components.apiBinder.deviceState.connected = true;
expect(await components.apiBinder.healthcheck()).to.equal(false); expect(await components.apiBinder.healthcheck()).to.equal(false);
expect(Log.info).to.be.calledOnce; expect(Log.info).to.be.calledOnce;
expect((Log.info as SinonSpy).lastCall?.lastArg).to.equal( expect((Log.info as SinonSpy).lastCall?.lastArg).to.equal(
stripIndent` stripIndent`
@ -455,10 +476,10 @@ describe('ApiBinder', () => {
- device state is disconnected ? false - device state is disconnected ? false
- stateReportErrors less then 3 ? false`, - stateReportErrors less then 3 ? false`,
); );
// Restore previous values // Restore previous values
// @ts-expect-error components.apiBinder.stateReportErrors = previousStateReportErrors;
CurrentState.stateReportErrors = previousStateReportErrors; components.deviceState.connected = previousDeviceStateConnected;
components.apiBinder.deviceState.connected = previousDeviceStateConnected;
}); });
}); });
}); });

View File

@ -6,7 +6,7 @@ import Network from '../src/compose/network';
import Service from '../src/compose/service'; import Service from '../src/compose/service';
import Volume from '../src/compose/volume'; import Volume from '../src/compose/volume';
import DeviceState from '../src/device-state'; import * as deviceState from '../src/device-state';
import * as dockerUtils from '../src/lib/docker-utils'; import * as dockerUtils from '../src/lib/docker-utils';
import * as images from '../src/compose/images'; import * as images from '../src/compose/images';
@ -18,6 +18,8 @@ import * as targetStateCache from '../src/device-state/target-state-cache';
import * as config from '../src/config'; import * as config from '../src/config';
import { TargetApplication, TargetApplications } from '../src/types/state'; import { TargetApplication, TargetApplications } from '../src/types/state';
import * as applicationManager from '../src/compose/application-manager';
// tslint:disable-next-line // tslint:disable-next-line
chai.use(require('chai-events')); chai.use(require('chai-events'));
const { expect } = chai; const { expect } = chai;
@ -63,14 +65,13 @@ const dependentDBFormat = {
imageId: 45, imageId: 45,
}; };
describe('ApplicationManager', function () { describe.skip('ApplicationManager', function () {
const originalInspectByName = images.inspectByName; const originalInspectByName = images.inspectByName;
before(async function () { before(async function () {
await prepare(); await prepare();
this.deviceState = new DeviceState({ await deviceState.initialized;
apiBinder: null as any,
}); this.applications = applicationManager;
this.applications = this.deviceState.applications;
// @ts-expect-error assigning to a RO property // @ts-expect-error assigning to a RO property
images.inspectByName = () => images.inspectByName = () =>
@ -178,8 +179,8 @@ describe('ApplicationManager', function () {
targetStateCache.targetState = undefined; targetStateCache.targetState = undefined;
}); });
it('should init', function () { it('should init', async () => {
return this.applications.init(); await applicationManager.initialized;
}); });
it('infers a start step when all that changes is a running state', function () { it('infers a start step when all that changes is a running state', function () {

View File

@ -1,57 +1,40 @@
import { SinonStub, stub } from 'sinon'; import { SinonStub, stub } from 'sinon';
import { expect } from './lib/chai-config';
import * as _ from 'lodash';
import APIBinder from '../src/api-binder'; import * as apiBinder from '../src/api-binder';
import { ApplicationManager } from '../src/application-manager'; import * as applicationManager from '../src/compose/application-manager';
import DeviceState from '../src/device-state'; import * as deviceState from '../src/device-state';
import * as constants from '../src/lib/constants'; import * as constants from '../src/lib/constants';
import { docker } from '../src/lib/docker-utils'; import { docker } from '../src/lib/docker-utils';
import { Supervisor } from '../src/supervisor'; import { Supervisor } from '../src/supervisor';
import { expect } from './lib/chai-config';
import _ = require('lodash');
describe('Startup', () => { describe('Startup', () => {
let initClientStub: SinonStub;
let reportCurrentStateStub: SinonStub;
let startStub: SinonStub; let startStub: SinonStub;
let vpnStatusPathStub: SinonStub; let vpnStatusPathStub: SinonStub;
let appManagerStub: SinonStub;
let deviceStateStub: SinonStub; let deviceStateStub: SinonStub;
let dockerStub: SinonStub; let dockerStub: SinonStub;
before(() => { before(async () => {
initClientStub = stub(APIBinder.prototype as any, 'initClient').returns( startStub = stub(apiBinder as any, 'start').resolves();
Promise.resolve(), deviceStateStub = stub(deviceState, 'applyTarget').resolves();
); // @ts-expect-error
reportCurrentStateStub = stub( applicationManager.initialized = Promise.resolve();
DeviceState.prototype as any,
'reportCurrentState',
).resolves();
startStub = stub(APIBinder.prototype as any, 'start').returns(
Promise.resolve(),
);
appManagerStub = stub(ApplicationManager.prototype, 'init').returns(
Promise.resolve(),
);
vpnStatusPathStub = stub(constants, 'vpnStatusPath').returns(''); vpnStatusPathStub = stub(constants, 'vpnStatusPath').returns('');
deviceStateStub = stub(DeviceState.prototype as any, 'applyTarget').returns(
Promise.resolve(),
);
dockerStub = stub(docker, 'listContainers').returns(Promise.resolve([])); dockerStub = stub(docker, 'listContainers').returns(Promise.resolve([]));
}); });
after(() => { after(() => {
initClientStub.restore();
startStub.restore(); startStub.restore();
appManagerStub.restore();
vpnStatusPathStub.restore(); vpnStatusPathStub.restore();
deviceStateStub.restore(); deviceStateStub.restore();
dockerStub.restore(); dockerStub.restore();
reportCurrentStateStub.restore();
}); });
it('should startup correctly', async () => { it('should startup correctly', async () => {
const supervisor = new Supervisor(); const supervisor = new Supervisor();
expect(await supervisor.init()).to.not.throw; await supervisor.init();
// Cast as any to access private properties // Cast as any to access private properties
const anySupervisor = supervisor as any; const anySupervisor = supervisor as any;
expect(anySupervisor.db).to.not.be.null; expect(anySupervisor.db).to.not.be.null;
@ -59,20 +42,5 @@ describe('Startup', () => {
expect(anySupervisor.logger).to.not.be.null; expect(anySupervisor.logger).to.not.be.null;
expect(anySupervisor.deviceState).to.not.be.null; expect(anySupervisor.deviceState).to.not.be.null;
expect(anySupervisor.apiBinder).to.not.be.null; expect(anySupervisor.apiBinder).to.not.be.null;
let macAddresses: string[] = [];
reportCurrentStateStub.getCalls().forEach((call) => {
const m: string = call.args[0]['mac_address'];
if (!m) {
return;
}
macAddresses = _.union(macAddresses, m.split(' '));
});
const allMacAddresses = macAddresses.join(' ');
expect(allMacAddresses).to.have.length.greaterThan(0);
expect(allMacAddresses).to.not.contain('NO:');
}); });
}); });

View File

@ -2,14 +2,16 @@ import { expect } from 'chai';
import { spy, stub, SinonStub } from 'sinon'; import { spy, stub, SinonStub } from 'sinon';
import * as supertest from 'supertest'; import * as supertest from 'supertest';
import APIBinder from '../src/api-binder'; import * as apiBinder from '../src/api-binder';
import DeviceState from '../src/device-state'; import * as deviceState from '../src/device-state';
import Log from '../src/lib/supervisor-console'; import Log from '../src/lib/supervisor-console';
import * as images from '../src/compose/images';
import SupervisorAPI from '../src/supervisor-api'; import SupervisorAPI from '../src/supervisor-api';
import sampleResponses = require('./data/device-api-responses.json'); import sampleResponses = require('./data/device-api-responses.json');
import mockedAPI = require('./lib/mocked-device-api'); import mockedAPI = require('./lib/mocked-device-api');
import * as applicationManager from '../src/compose/application-manager';
import { InstancedAppState } from '../src/types/state';
const mockedOptions = { const mockedOptions = {
listenPort: 54321, listenPort: 54321,
timeout: 30000, timeout: 30000,
@ -21,23 +23,23 @@ describe('SupervisorAPI', () => {
let api: SupervisorAPI; let api: SupervisorAPI;
let healthCheckStubs: SinonStub[]; let healthCheckStubs: SinonStub[];
const request = supertest(`http://127.0.0.1:${mockedOptions.listenPort}`); const request = supertest(`http://127.0.0.1:${mockedOptions.listenPort}`);
const originalGetStatus = images.getStatus;
before(async () => { before(async () => {
await apiBinder.initialized;
await deviceState.initialized;
// Stub health checks so we can modify them whenever needed // Stub health checks so we can modify them whenever needed
healthCheckStubs = [ healthCheckStubs = [
stub(APIBinder.prototype, 'healthcheck'), stub(apiBinder, 'healthcheck'),
stub(DeviceState.prototype, 'healthcheck'), stub(deviceState, 'healthcheck'),
]; ];
// The mockedAPI contains stubs that might create unexpected results // The mockedAPI contains stubs that might create unexpected results
// See the module to know what has been stubbed // See the module to know what has been stubbed
api = await mockedAPI.create(); api = await mockedAPI.create();
// @ts-expect-error assigning to a RO property
images.getStatus = () => Promise.resolve([]);
// Start test API // Start test API
return api.listen(mockedOptions.listenPort, mockedOptions.timeout); await api.listen(mockedOptions.listenPort, mockedOptions.timeout);
}); });
after(async () => { after(async () => {
@ -52,9 +54,6 @@ describe('SupervisorAPI', () => {
healthCheckStubs.forEach((hc) => hc.restore); healthCheckStubs.forEach((hc) => hc.restore);
// Remove any test data generated // Remove any test data generated
await mockedAPI.cleanUp(); await mockedAPI.cleanUp();
// @ts-expect-error assigning to a RO property
images.getStatus = originalGetStatus;
}); });
describe('/ping', () => { describe('/ping', () => {
@ -107,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 // TODO: add tests for V1 endpoints
describe('GET /v1/apps/:appId', () => { describe('GET /v1/apps/:appId', () => {
it('returns information about a SPECIFIC application', async () => { it('returns information about a SPECIFIC application', async () => {
@ -114,8 +139,8 @@ describe('SupervisorAPI', () => {
.get('/v1/apps/2') .get('/v1/apps/2')
.set('Accept', 'application/json') .set('Accept', 'application/json')
.set('Authorization', `Bearer ${VALID_SECRET}`) .set('Authorization', `Bearer ${VALID_SECRET}`)
.expect('Content-Type', /json/)
.expect(sampleResponses.V1.GET['/apps/2'].statusCode) .expect(sampleResponses.V1.GET['/apps/2'].statusCode)
.expect('Content-Type', /json/)
.then((response) => { .then((response) => {
expect(response.body).to.deep.equal( expect(response.body).to.deep.equal(
sampleResponses.V1.GET['/apps/2'].body, sampleResponses.V1.GET['/apps/2'].body,
@ -130,8 +155,8 @@ describe('SupervisorAPI', () => {
.post('/v1/apps/2/stop') .post('/v1/apps/2/stop')
.set('Accept', 'application/json') .set('Accept', 'application/json')
.set('Authorization', `Bearer ${VALID_SECRET}`) .set('Authorization', `Bearer ${VALID_SECRET}`)
.expect('Content-Type', /json/)
.expect(sampleResponses.V1.GET['/apps/2/stop'].statusCode) .expect(sampleResponses.V1.GET['/apps/2/stop'].statusCode)
.expect('Content-Type', /json/)
.then((response) => { .then((response) => {
expect(response.body).to.deep.equal( expect(response.body).to.deep.equal(
sampleResponses.V1.GET['/apps/2/stop'].body, sampleResponses.V1.GET['/apps/2/stop'].body,

View File

@ -1,14 +1,23 @@
import { expect } from 'chai'; import { expect } from 'chai';
import prepare = require('./lib/prepare'); import prepare = require('./lib/prepare');
import * as _ from 'lodash';
import * as config from '../src/config'; import * as config from '../src/config';
import * as dbFormat from '../src/device-state/db-format'; import * as dbFormat from '../src/device-state/db-format';
import * as targetStateCache from '../src/device-state/target-state-cache'; import * as targetStateCache from '../src/device-state/target-state-cache';
import * as images from '../src/compose/images'; import * as images from '../src/compose/images';
import App from '../src/compose/app';
import Service from '../src/compose/service'; import Service from '../src/compose/service';
import Network from '../src/compose/network';
import { TargetApplication } from '../src/types/state'; import { TargetApplication } from '../src/types/state';
function getDefaultNetworks(appId: number) {
return {
default: Network.fromComposeObject('default', appId, {}),
};
}
describe('DB Format', () => { describe('DB Format', () => {
const originalInspect = images.inspectByName; const originalInspect = images.inspectByName;
let apiEndpoint: string; let apiEndpoint: string;
@ -74,24 +83,28 @@ describe('DB Format', () => {
it('should retrieve a single app from the database', async () => { it('should retrieve a single app from the database', async () => {
const app = await dbFormat.getApp(1); 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('appId').that.equals(1);
expect(app).to.have.property('commit').that.equals('abcdef'); expect(app).to.have.property('commit').that.equals('abcdef');
expect(app).to.have.property('releaseId').that.equals(123); 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) expect(app)
.to.have.property('source') .to.have.property('source')
.that.deep.equals(await config.get('apiEndpoint')); .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('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 () => { it('should correctly build services from the database', async () => {
const app = await dbFormat.getApp(2); const app = await dbFormat.getApp(2);
expect(app).to.have.property('services').that.is.an('object'); expect(app).to.have.property('services').that.is.an('array');
expect(Object.keys(app.services)).to.deep.equal(['567']); 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); expect(service).to.be.instanceof(Service);
// Don't do a deep equals here as a bunch of other properties are added that are // Don't do a deep equals here as a bunch of other properties are added that are
// tested elsewhere // tested elsewhere
@ -120,9 +133,9 @@ describe('DB Format', () => {
const app = await dbFormat.getApp(1234); const app = await dbFormat.getApp(1234);
expect(app).to.have.property('name').that.equals('pi4test'); expect(app).to.have.property('appName').that.equals('pi4test');
expect(app).to.have.property('services').that.is.an('object'); expect(app).to.have.property('services').that.is.an('array');
expect(app.services) expect(_.keyBy(app.services, 'serviceId'))
.to.have.property('482141') .to.have.property('482141')
.that.has.property('serviceName') .that.has.property('serviceName')
.that.equals('main'); .that.equals('main');
@ -138,7 +151,8 @@ describe('DB Format', () => {
}); });
const app = await dbFormat.getApp(2); 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) expect(conf)
.to.have.property('entrypoint') .to.have.property('entrypoint')
.that.deep.equals(['theEntrypoint']); .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", "HostnamePath": "/var/lib/docker/containers/52cfd7a64d50236376741dd2578c4fbb0178d90e2e4fae55f3e14cd905e9ac9e/hostname",
"HostsPath": "/var/lib/docker/containers/52cfd7a64d50236376741dd2578c4fbb0178d90e2e4fae55f3e14cd905e9ac9e/hosts", "HostsPath": "/var/lib/docker/containers/52cfd7a64d50236376741dd2578c4fbb0178d90e2e4fae55f3e14cd905e9ac9e/hosts",
"LogPath": "", "LogPath": "",
"Name": "/nifty_swartz", "Name": "main_1_1",
"RestartCount": 0, "RestartCount": 0,
"Driver": "aufs", "Driver": "aufs",
"Platform": "linux", "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 --exit
--require ts-node/register/transpile-only --require ts-node/register/transpile-only
--timeout 30000 --timeout 30000
--bail
test/*.{ts,js} 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

@ -52,6 +52,12 @@ api.get(/\/v6\/device\(uuid=%27([0-9a-f]+)%27\)/, (req, res) =>
api.balenaBackend!.getDeviceHandler(req, res, _.noop), api.balenaBackend!.getDeviceHandler(req, res, _.noop),
); );
api.get(/\/v6\/device/, (req, res) => {
const [, uuid] = /uuid eq '([0-9a-f]+)'/i.exec(req.query['$filter']) ?? [];
req.params[0] = uuid;
return api.balenaBackend!.getDeviceHandler(req, res, _.noop);
});
api.post('/api-key/device/:deviceId/device-key', (req, res) => api.post('/api-key/device/:deviceId/device-key', (req, res) =>
api.balenaBackend!.deviceKeyHandler(req, res, _.noop), api.balenaBackend!.deviceKeyHandler(req, res, _.noop),
); );

View File

@ -2,7 +2,7 @@ import * as _ from 'lodash';
import { Router } from 'express'; import { Router } from 'express';
import { fs } from 'mz'; 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 networkManager from '../../src/compose/network-manager';
import * as serviceManager from '../../src/compose/service-manager'; import * as serviceManager from '../../src/compose/service-manager';
import * as volumeManager from '../../src/compose/volume-manager'; import * as volumeManager from '../../src/compose/volume-manager';
@ -10,8 +10,8 @@ import * as config from '../../src/config';
import * as db from '../../src/db'; import * as db from '../../src/db';
import { createV1Api } from '../../src/device-api/v1'; import { createV1Api } from '../../src/device-api/v1';
import { createV2Api } from '../../src/device-api/v2'; import { createV2Api } from '../../src/device-api/v2';
import APIBinder from '../../src/api-binder'; import * as apiBinder from '../../src/api-binder';
import DeviceState from '../../src/device-state'; import * as deviceState from '../../src/device-state';
import SupervisorAPI from '../../src/supervisor-api'; import SupervisorAPI from '../../src/supervisor-api';
const DB_PATH = './test/data/supervisor-api.sqlite'; const DB_PATH = './test/data/supervisor-api.sqlite';
@ -67,17 +67,14 @@ const STUBBED_VALUES = {
async function create(): Promise<SupervisorAPI> { async function create(): Promise<SupervisorAPI> {
// Get SupervisorAPI construct options // Get SupervisorAPI construct options
const { deviceState, apiBinder } = await createAPIOpts(); await createAPIOpts();
// Stub functions // Stub functions
setupStubs(); setupStubs();
// Create ApplicationManager
const appManager = new ApplicationManager({
deviceState,
apiBinder: null,
});
// Create SupervisorAPI // Create SupervisorAPI
const api = new SupervisorAPI({ const api = new SupervisorAPI({
routers: [deviceState.router, buildRoutes(appManager)], routers: [deviceState.router, buildRoutes()],
healthchecks: [deviceState.healthcheck, apiBinder.healthcheck], healthchecks: [deviceState.healthcheck, apiBinder.healthcheck],
}); });
@ -101,19 +98,12 @@ async function cleanUp(): Promise<void> {
return restoreStubs(); return restoreStubs();
} }
async function createAPIOpts(): Promise<SupervisorAPIOpts> { async function createAPIOpts(): Promise<void> {
await db.initialized; await db.initialized;
await deviceState.initialized;
// Initialize and set values for mocked Config // Initialize and set values for mocked Config
await initConfig(); await initConfig();
// Create deviceState
const deviceState = new DeviceState({
apiBinder: null as any,
});
const apiBinder = new APIBinder();
return {
deviceState,
apiBinder,
};
} }
async function initConfig(): Promise<void> { async function initConfig(): Promise<void> {
@ -129,13 +119,13 @@ async function initConfig(): Promise<void> {
}); });
} }
function buildRoutes(appManager: ApplicationManager): Router { function buildRoutes(): Router {
// Create new Router // Create new Router
const router = Router(); const router = Router();
// Add V1 routes // Add V1 routes
createV1Api(router, appManager); createV1Api(applicationManager.router);
// Add V2 routes // Add V2 routes
createV2Api(router, appManager); createV2Api(applicationManager.router);
// Return modified Router // Return modified Router
return router; return router;
} }
@ -168,9 +158,4 @@ function restoreStubs() {
serviceManager.getAllByAppId = originalSvcGetAppId; serviceManager.getAllByAppId = originalSvcGetAppId;
} }
interface SupervisorAPIOpts {
deviceState: DeviceState;
apiBinder: APIBinder;
}
export = { create, cleanUp, STUBBED_VALUES }; export = { create, cleanUp, STUBBED_VALUES };

View File

@ -1,4 +1,58 @@
process.env.DOCKER_HOST = 'unix:///your/dockerode/mocks/are/not/working';
import * as dockerode from 'dockerode'; 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 { export interface TestData {
networks: Dictionary<any>; networks: Dictionary<any>;

View File

@ -0,0 +1,4 @@
import * as eventTracker from '../../src/event-tracker';
import { spy } from 'sinon';
export const eventTrackSpy = spy(eventTracker, 'track');

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 Nullable<T> = T | null | undefined;
type Resolvable<T> = T | Promise<T>; 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 type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> } ? { [K in keyof T]?: DeepPartial<T[K]> }