mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-05-04 18:12:56 +00:00
Merge pull request #1408 from balena-io/refactor-to-singletons
Refactor to singletons
This commit is contained in:
commit
4a5874c510
28
package-lock.json
generated
28
package-lock.json
generated
@ -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",
|
||||||
|
@ -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",
|
||||||
|
1318
src/api-binder.ts
1318
src/api-binder.ts
File diff suppressed because it is too large
Load Diff
100
src/application-manager.d.ts
vendored
100
src/application-manager.d.ts
vendored
@ -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
802
src/compose/app.ts
Normal 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;
|
846
src/compose/application-manager.ts
Normal file
846
src/compose/application-manager.ts
Normal 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 };
|
||||||
|
}
|
@ -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;
|
||||||
|
@ -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> {
|
||||||
|
@ -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 {
|
||||||
|
@ -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}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
57
src/compose/update-strategies.ts
Normal file
57
src/compose/update-strategies.ts
Normal 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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
13
src/device-api/common.d.ts
vendored
13
src/device-api/common.d.ts
vendored
@ -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,
|
||||||
|
@ -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,
|
||||||
});
|
});
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
|
@ -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);
|
||||||
|
1341
src/device-state.ts
1341
src/device-state.ts
File diff suppressed because it is too large
Load Diff
@ -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
|
||||||
|
@ -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) {
|
||||||
|
@ -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
213
src/lib/api-helper.ts
Normal 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;
|
||||||
|
};
|
@ -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 {}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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/`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
|
@ -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: {
|
||||||
|
@ -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';
|
||||||
|
@ -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;
|
||||||
|
@ -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,
|
||||||
|
@ -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;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
@ -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;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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 () {
|
||||||
|
@ -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:');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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,
|
||||||
|
@ -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']);
|
||||||
|
263
test/32-compose-app-manager.spec.ts
Normal file
263
test/32-compose-app-manager.spec.ts
Normal 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
1039
test/34-compose-app.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||||
|
30
test/data/docker-states/networks/1623449_default.json
Normal file
30
test/data/docker-states/networks/1623449_default.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
270
test/data/docker-states/supervisor-api/inspect.json
Normal file
270
test/data/docker-states/supervisor-api/inspect.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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}
|
||||||
|
78
test/lib/application-state-mock.ts
Normal file
78
test/lib/application-state-mock.ts
Normal 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();
|
||||||
|
}
|
@ -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),
|
||||||
);
|
);
|
||||||
|
@ -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 };
|
||||||
|
@ -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>;
|
||||||
|
4
test/lib/mocked-event-tracker.ts
Normal file
4
test/lib/mocked-event-tracker.ts
Normal 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
2
typings/global.d.ts
vendored
@ -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]> }
|
||||||
|
Loading…
x
Reference in New Issue
Block a user