mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2024-12-24 07:46:41 +00:00
Merge pull request #1765 from balena-os/v3-target-state
Update supervisor to use new v3 target state format
This commit is contained in:
commit
a89b23ac7c
41
package-lock.json
generated
41
package-lock.json
generated
@ -6627,6 +6627,12 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
|
||||
"integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk="
|
||||
},
|
||||
"lodash.set": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz",
|
||||
"integrity": "sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=",
|
||||
"dev": true
|
||||
},
|
||||
"log-symbols": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.0.0.tgz",
|
||||
@ -7635,6 +7641,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"nock": {
|
||||
"version": "13.1.2",
|
||||
"resolved": "https://registry.npmjs.org/nock/-/nock-13.1.2.tgz",
|
||||
"integrity": "sha512-BDjokoeGZnBghmvwCcDJ1yM5TDRMRAJfGi1xIzX5rKTlifbyx1oRpAVl3aNhEA3kGbUSEPD7gBLmwVdnQibrIA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"debug": "^4.1.0",
|
||||
"json-stringify-safe": "^5.0.1",
|
||||
"lodash.set": "^4.3.2",
|
||||
"propagate": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"debug": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
|
||||
"integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ms": "2.1.2"
|
||||
}
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node-libs-browser": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz",
|
||||
@ -8415,6 +8450,12 @@
|
||||
"integrity": "sha1-mEcocL8igTL8vdhoEputEsPAKeM=",
|
||||
"dev": true
|
||||
},
|
||||
"propagate": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz",
|
||||
"integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==",
|
||||
"dev": true
|
||||
},
|
||||
"proxy-addr": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz",
|
||||
|
@ -108,6 +108,7 @@
|
||||
"mock-fs": "^4.14.0",
|
||||
"morgan": "^1.10.0",
|
||||
"network-checker": "^0.1.1",
|
||||
"nock": "^13.1.2",
|
||||
"nodemon": "^2.0.4",
|
||||
"pinejs-client-request": "^7.2.1",
|
||||
"pretty-ms": "^7.0.1",
|
||||
|
@ -6,34 +6,31 @@ import * as t from 'io-ts';
|
||||
import * as _ from 'lodash';
|
||||
import { PinejsClientRequest } from 'pinejs-client-request';
|
||||
import * as url from 'url';
|
||||
import * as deviceRegister from './lib/register-device';
|
||||
import * as deviceRegister from '../lib/register-device';
|
||||
|
||||
import * as config from './config';
|
||||
import * as deviceConfig from './device-config';
|
||||
import * as eventTracker from './event-tracker';
|
||||
import { loadBackupFromMigration } from './lib/migration';
|
||||
import * as config from '../config';
|
||||
import * as deviceConfig from '../device-config';
|
||||
import * as eventTracker from '../event-tracker';
|
||||
import { loadBackupFromMigration } from '../lib/migration';
|
||||
|
||||
import {
|
||||
ContractValidationError,
|
||||
ContractViolationError,
|
||||
InternalInconsistencyError,
|
||||
TargetStateError,
|
||||
} from './lib/errors';
|
||||
import * as request from './lib/request';
|
||||
} from '../lib/errors';
|
||||
import * as request from '../lib/request';
|
||||
|
||||
import log from './lib/supervisor-console';
|
||||
import log from '../lib/supervisor-console';
|
||||
|
||||
import * as deviceState from './device-state';
|
||||
import * as globalEventBus from './event-bus';
|
||||
import * as TargetState from './device-state/target-state';
|
||||
import * as logger from './logger';
|
||||
import * as deviceState from '../device-state';
|
||||
import * as globalEventBus from '../event-bus';
|
||||
import * as TargetState from '../device-state/target-state';
|
||||
import * as logger from '../logger';
|
||||
|
||||
import * as apiHelper from './lib/api-helper';
|
||||
import { Device } from './lib/api-helper';
|
||||
import {
|
||||
startReporting,
|
||||
stateReportErrors,
|
||||
} from './device-state/current-state';
|
||||
import * as apiHelper from '../lib/api-helper';
|
||||
import { Device } from '../lib/api-helper';
|
||||
import { startReporting, stateReportErrors } from './report';
|
||||
|
||||
interface DevicePinInfo {
|
||||
app: number;
|
||||
@ -215,7 +212,7 @@ export async function patchDevice(
|
||||
}
|
||||
|
||||
if (!conf.provisioned) {
|
||||
throw new Error('DEvice must be provisioned to update a device');
|
||||
throw new Error('Device must be provisioned to update a device');
|
||||
}
|
||||
|
||||
if (balenaApi == null) {
|
||||
@ -308,11 +305,7 @@ export async function fetchDeviceTags(): Promise<DeviceTag[]> {
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: id.right,
|
||||
name: name.right,
|
||||
value: value.right,
|
||||
};
|
||||
return { id: id.right, name: name.right, value: value.right };
|
||||
});
|
||||
}
|
||||
|
||||
@ -353,7 +346,12 @@ async function pinDevice({ app, commit }: DevicePinInfo) {
|
||||
|
||||
// We force a fresh get to make sure we have the latest state
|
||||
// and can guarantee we don't clash with any already reported config
|
||||
const targetConfigUnformatted = (await TargetState.get())?.local?.config;
|
||||
const uuid = await config.get('uuid');
|
||||
if (!uuid) {
|
||||
throw new InternalInconsistencyError('No uuid for local device');
|
||||
}
|
||||
|
||||
const targetConfigUnformatted = (await TargetState.get())?.[uuid]?.config;
|
||||
if (targetConfigUnformatted == null) {
|
||||
throw new InternalInconsistencyError(
|
||||
'Attempt to report initial state with malformed target state',
|
||||
@ -389,10 +387,12 @@ async function reportInitialEnv(
|
||||
);
|
||||
}
|
||||
|
||||
const targetConfigUnformatted = _.get(
|
||||
await TargetState.get(),
|
||||
'local.config',
|
||||
);
|
||||
const uuid = await config.get('uuid');
|
||||
if (!uuid) {
|
||||
throw new InternalInconsistencyError('No uuid for local device');
|
||||
}
|
||||
|
||||
const targetConfigUnformatted = (await TargetState.get())?.[uuid]?.config;
|
||||
if (targetConfigUnformatted == null) {
|
||||
throw new InternalInconsistencyError(
|
||||
'Attempt to report initial state with malformed target state',
|
||||
@ -401,17 +401,14 @@ async function reportInitialEnv(
|
||||
|
||||
const defaultConfig = deviceConfig.getDefaults();
|
||||
|
||||
const currentState = await deviceState.getCurrentState();
|
||||
const targetConfig = await deviceConfig.formatConfigKeys(
|
||||
targetConfigUnformatted,
|
||||
);
|
||||
const currentConfig = await deviceConfig.getCurrent();
|
||||
const targetConfig = deviceConfig.formatConfigKeys(targetConfigUnformatted);
|
||||
|
||||
if (!currentState.local.config) {
|
||||
if (!currentConfig) {
|
||||
throw new InternalInconsistencyError(
|
||||
'No config defined in reportInitialEnv',
|
||||
);
|
||||
}
|
||||
const currentConfig: Dictionary<string> = currentState.local.config;
|
||||
for (const [key, value] of _.toPairs(currentConfig)) {
|
||||
let varValue = value;
|
||||
// We want to disable local mode when joining a cloud
|
155
src/api-binder/report.ts
Normal file
155
src/api-binder/report.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import * as url from 'url';
|
||||
import * as _ from 'lodash';
|
||||
import { CoreOptions } from 'request';
|
||||
|
||||
import * as constants from '../lib/constants';
|
||||
import { withBackoff, OnFailureInfo } from '../lib/backoff';
|
||||
import { log } from '../lib/supervisor-console';
|
||||
import { InternalInconsistencyError, StatusError } from '../lib/errors';
|
||||
import { getRequestInstance } from '../lib/request';
|
||||
|
||||
import { DeviceState } from '../types';
|
||||
import * as config from '../config';
|
||||
import { SchemaTypeKey, SchemaReturn } from '../config/schema-type';
|
||||
import * as eventTracker from '../event-tracker';
|
||||
import * as deviceState from '../device-state';
|
||||
|
||||
import { shallowDiff, prune, empty } from '../lib/json';
|
||||
|
||||
let lastReport: DeviceState = {};
|
||||
let reportPending = false;
|
||||
export let stateReportErrors = 0;
|
||||
|
||||
type StateReportOpts = {
|
||||
[key in keyof Pick<
|
||||
config.ConfigMap<SchemaTypeKey>,
|
||||
'apiEndpoint' | 'apiTimeout' | 'deviceApiKey' | 'appUpdatePollInterval'
|
||||
>]: SchemaReturn<key>;
|
||||
};
|
||||
|
||||
type StateReport = { body: Partial<DeviceState>; opts: StateReportOpts };
|
||||
|
||||
async function report({ body, opts }: StateReport) {
|
||||
const { apiEndpoint, apiTimeout, deviceApiKey } = opts;
|
||||
if (empty(body)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!apiEndpoint) {
|
||||
throw new InternalInconsistencyError(
|
||||
'No apiEndpoint available for patching current state',
|
||||
);
|
||||
}
|
||||
|
||||
const endpoint = url.resolve(apiEndpoint, `/device/v3/state`);
|
||||
const request = await getRequestInstance();
|
||||
|
||||
const params: CoreOptions = {
|
||||
json: true,
|
||||
headers: {
|
||||
Authorization: `Bearer ${deviceApiKey}`,
|
||||
},
|
||||
body,
|
||||
};
|
||||
|
||||
const [
|
||||
{ statusCode, body: statusMessage, headers },
|
||||
] = await request.patchAsync(endpoint, params).timeout(apiTimeout);
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
throw new StatusError(
|
||||
statusCode,
|
||||
JSON.stringify(statusMessage, null, 2),
|
||||
headers['retry-after'] ? parseInt(headers['retry-after'], 10) : undefined,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function reportCurrentState(opts: StateReportOpts) {
|
||||
// Ensure no other report starts
|
||||
reportPending = true;
|
||||
// Wrap the report with fetching of state so report always has the latest state diff
|
||||
const getStateAndReport = async () => {
|
||||
// Get state to report
|
||||
const currentState = await deviceState.getCurrentForReport(lastReport);
|
||||
|
||||
// Depth 2 is the apps level
|
||||
const stateDiff = prune(shallowDiff(lastReport, currentState, 2));
|
||||
|
||||
// Report diff
|
||||
if (await report({ body: stateDiff, opts })) {
|
||||
// Update lastReportedState if the report succeeds
|
||||
lastReport = currentState;
|
||||
// Log that we successfully reported the current state
|
||||
log.info('Reported current state to the cloud');
|
||||
}
|
||||
};
|
||||
|
||||
// Create a report that will backoff on errors
|
||||
const reportWithBackoff = withBackoff(getStateAndReport, {
|
||||
maxDelay: opts.appUpdatePollInterval,
|
||||
minDelay: 15000,
|
||||
onFailure: handleRetry,
|
||||
});
|
||||
|
||||
// Run in try block to avoid throwing any exceptions
|
||||
try {
|
||||
await reportWithBackoff();
|
||||
stateReportErrors = 0;
|
||||
} catch (e) {
|
||||
log.error(e);
|
||||
}
|
||||
reportPending = false;
|
||||
}
|
||||
|
||||
function handleRetry(retryInfo: OnFailureInfo) {
|
||||
if (retryInfo.error instanceof StatusError) {
|
||||
// We don't want these errors to be classed as a report error, as this will cause
|
||||
// the watchdog to kill the supervisor - and killing the supervisor will
|
||||
// not help in this situation
|
||||
log.error(
|
||||
`Device state report failure! Status code: ${retryInfo.error.statusCode} - message:`,
|
||||
retryInfo.error?.message ?? retryInfo.error,
|
||||
);
|
||||
} else {
|
||||
eventTracker.track('Device state report failure', {
|
||||
error: retryInfo.error?.message ?? retryInfo.error,
|
||||
});
|
||||
|
||||
// Increase the counter so the healthcheck gets triggered
|
||||
// if too many connectivity errors occur
|
||||
stateReportErrors++;
|
||||
}
|
||||
log.info(
|
||||
`Retrying current state report in ${retryInfo.delay / 1000} seconds`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function startReporting() {
|
||||
// Get configs needed to make a report
|
||||
const reportConfigs = (await config.getMany([
|
||||
'apiEndpoint',
|
||||
'apiTimeout',
|
||||
'deviceApiKey',
|
||||
'appUpdatePollInterval',
|
||||
])) as StateReportOpts;
|
||||
// Throttle reportCurrentState so we don't query device or hit API excessively
|
||||
const throttledReport = _.throttle(
|
||||
reportCurrentState,
|
||||
constants.maxReportFrequency,
|
||||
);
|
||||
const doReport = async () => {
|
||||
if (!reportPending) {
|
||||
throttledReport(reportConfigs);
|
||||
}
|
||||
};
|
||||
|
||||
// If the state changes, report it
|
||||
deviceState.on('change', doReport);
|
||||
// But check once every max report frequency to ensure that changes in system
|
||||
// info are picked up (CPU temp etc)
|
||||
setInterval(doReport, constants.maxReportFrequency);
|
||||
// Try to perform a report right away
|
||||
return doReport();
|
||||
}
|
@ -26,12 +26,15 @@ import { checkTruthy, checkString } from '../lib/validation';
|
||||
import { ServiceComposeConfig, DeviceMetadata } from './types/service';
|
||||
import { ImageInspectInfo } from 'dockerode';
|
||||
import { pathExistsOnHost } from '../lib/fs-utils';
|
||||
import { getSupervisorMetadata } from '../lib/supervisor-metadata';
|
||||
|
||||
export interface AppConstructOpts {
|
||||
appId: number;
|
||||
appUuid?: string;
|
||||
appName?: string;
|
||||
commit?: string;
|
||||
source?: string;
|
||||
isHost?: boolean;
|
||||
|
||||
services: Service[];
|
||||
volumes: Dictionary<Volume>;
|
||||
@ -52,10 +55,12 @@ interface ChangingPair<T> {
|
||||
|
||||
export class App {
|
||||
public appId: number;
|
||||
public appUuid?: string;
|
||||
// When setting up an application from current state, these values are not available
|
||||
public appName?: string;
|
||||
public commit?: string;
|
||||
public source?: string;
|
||||
public isHost?: boolean;
|
||||
|
||||
// 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)
|
||||
@ -65,18 +70,21 @@ export class App {
|
||||
|
||||
public constructor(opts: AppConstructOpts, public isTargetState: boolean) {
|
||||
this.appId = opts.appId;
|
||||
this.appUuid = opts.appUuid;
|
||||
this.appName = opts.appName;
|
||||
this.commit = opts.commit;
|
||||
this.source = opts.source;
|
||||
this.services = opts.services;
|
||||
this.volumes = opts.volumes;
|
||||
this.networks = opts.networks;
|
||||
this.isHost = !!opts.isHost;
|
||||
|
||||
if (this.networks.default == null && isTargetState) {
|
||||
// We always want a default network
|
||||
this.networks.default = Network.fromComposeObject(
|
||||
'default',
|
||||
opts.appId,
|
||||
opts.appUuid!, // app uuid always exists on the target state
|
||||
{},
|
||||
);
|
||||
}
|
||||
@ -160,7 +168,6 @@ export class App {
|
||||
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,
|
||||
@ -726,13 +733,13 @@ export class App {
|
||||
if (conf.labels == null) {
|
||||
conf.labels = {};
|
||||
}
|
||||
return Volume.fromComposeObject(name, app.appId, conf);
|
||||
return Volume.fromComposeObject(name, app.appId, app.uuid, conf);
|
||||
});
|
||||
|
||||
const networks = _.mapValues(
|
||||
JSON.parse(app.networks) ?? {},
|
||||
(conf, name) => {
|
||||
return Network.fromComposeObject(name, app.appId, conf ?? {});
|
||||
return Network.fromComposeObject(name, app.appId, app.uuid, conf ?? {});
|
||||
},
|
||||
);
|
||||
|
||||
@ -767,11 +774,38 @@ export class App {
|
||||
...opts,
|
||||
};
|
||||
|
||||
const supervisorMeta = await getSupervisorMetadata();
|
||||
|
||||
const isService = (svc: ServiceComposeConfig) =>
|
||||
svc.labels?.['io.balena.image.class'] == null ||
|
||||
svc.labels['io.balena.image.class'] === 'service';
|
||||
|
||||
const isDataStore = (svc: ServiceComposeConfig) =>
|
||||
svc.labels?.['io.balena.image.store'] == null ||
|
||||
svc.labels['io.balena.image.store'] === 'data';
|
||||
|
||||
const isSupervisor = (svc: ServiceComposeConfig) =>
|
||||
app.uuid === supervisorMeta.uuid &&
|
||||
(svc.serviceName === supervisorMeta.serviceName ||
|
||||
// keep compatibility with older supervisor releases
|
||||
svc.serviceName === 'main');
|
||||
|
||||
// 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) => {
|
||||
JSON.parse(app.services ?? [])
|
||||
.filter(
|
||||
// For the host app, `io.balena.image.*` labels indicate special way
|
||||
// to install the service image, so we ignore those we don't know how to
|
||||
// handle yet. If a user app adds the labels, we treat those services
|
||||
// just as any other
|
||||
(svc: ServiceComposeConfig) =>
|
||||
!app.isHost || (isService(svc) && isDataStore(svc)),
|
||||
)
|
||||
// Ignore the supervisor service itself from the target state for now
|
||||
// until the supervisor can update itself
|
||||
.filter((svc: ServiceComposeConfig) => !isSupervisor(svc))
|
||||
.map(async (svc: ServiceComposeConfig) => {
|
||||
// Try to fill the image id if the image is downloaded
|
||||
let imageInfo: ImageInspectInfo | undefined;
|
||||
try {
|
||||
@ -793,15 +827,17 @@ export class App {
|
||||
svc,
|
||||
(thisSvcOpts as unknown) as DeviceMetadata,
|
||||
);
|
||||
},
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
return new App(
|
||||
{
|
||||
appId: app.appId,
|
||||
appUuid: app.uuid,
|
||||
commit: app.commit,
|
||||
appName: app.name,
|
||||
source: app.source,
|
||||
isHost: app.isHost,
|
||||
services,
|
||||
volumes,
|
||||
networks,
|
||||
|
@ -27,6 +27,8 @@ import { getExecutors, CompositionStepT } from './composition-steps';
|
||||
import * as commitStore from './commit';
|
||||
|
||||
import Service from './service';
|
||||
import Network from './network';
|
||||
import Volume from './volume';
|
||||
|
||||
import { createV1Api } from '../device-api/v1';
|
||||
import { createV2Api } from '../device-api/v2';
|
||||
@ -34,17 +36,17 @@ import { CompositionStep, generateStep } from './composition-steps';
|
||||
import {
|
||||
InstancedAppState,
|
||||
TargetApps,
|
||||
DeviceStatus,
|
||||
DeviceReportFields,
|
||||
TargetState,
|
||||
DeviceLegacyReport,
|
||||
AppState,
|
||||
ServiceState,
|
||||
} from '../types/state';
|
||||
import { checkTruthy, checkInt } from '../lib/validation';
|
||||
import { checkTruthy } from '../lib/validation';
|
||||
import { Proxyvisor } from '../proxyvisor';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
type ApplicationManagerEventEmitter = StrictEventEmitter<
|
||||
EventEmitter,
|
||||
{ change: DeviceReportFields }
|
||||
{ change: DeviceLegacyReport }
|
||||
>;
|
||||
const events: ApplicationManagerEventEmitter = new EventEmitter();
|
||||
export const on: typeof events['on'] = events.on.bind(events);
|
||||
@ -158,16 +160,16 @@ function reportCurrentState(data?: Partial<InstancedAppState>) {
|
||||
}
|
||||
|
||||
export async function getRequiredSteps(
|
||||
currentApps: InstancedAppState,
|
||||
targetApps: InstancedAppState,
|
||||
ignoreImages: boolean = false,
|
||||
): Promise<CompositionStep[]> {
|
||||
// get some required data
|
||||
const [downloading, availableImages, currentApps] = await Promise.all([
|
||||
const [downloading, availableImages] = await Promise.all([
|
||||
imageManager.getDownloadingImageNames(),
|
||||
imageManager.getAvailable(),
|
||||
getCurrentApps(),
|
||||
]);
|
||||
const containerIdsByAppId = await getAppContainerIds(currentApps);
|
||||
const containerIdsByAppId = getAppContainerIds(currentApps);
|
||||
|
||||
return await inferNextSteps(currentApps, targetApps, {
|
||||
ignoreImages,
|
||||
@ -353,50 +355,154 @@ export async function stopAll({ force = false, skipLock = false } = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// The following two function may look pretty odd, but after the move to uuids,
|
||||
// there's a chance that the current running apps don't have a uuid set. We
|
||||
// still need to be able to work on these and perform various state changes. To
|
||||
// do this we try to use the UUID to group the components, and if that isn't
|
||||
// available we revert to using the appIds instead
|
||||
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));
|
||||
const componentGroups = groupComponents(
|
||||
await serviceManager.getAll(),
|
||||
await networkManager.getAll(),
|
||||
await volumeManager.getAll(),
|
||||
);
|
||||
|
||||
const apps: InstancedAppState = {};
|
||||
for (const appId of allAppIds) {
|
||||
for (const strAppId of Object.keys(componentGroups)) {
|
||||
const appId = parseInt(strAppId, 10);
|
||||
|
||||
// TODO: get commit and release version from container
|
||||
const commit = await commitStore.getCommitForApp(appId);
|
||||
apps[appId] = new App(
|
||||
{
|
||||
appId,
|
||||
services: services[appId] ?? [],
|
||||
networks: _.keyBy(networks[appId], 'name'),
|
||||
volumes: _.keyBy(volumes[appId], 'name'),
|
||||
commit,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
const components = componentGroups[appId];
|
||||
|
||||
// fetch the correct uuid from any component within the appId
|
||||
const uuid = [
|
||||
components.services[0]?.appUuid,
|
||||
components.volumes[0]?.appUuid,
|
||||
components.networks[0]?.appUuid,
|
||||
]
|
||||
.filter((u) => !!u)
|
||||
.shift()!;
|
||||
|
||||
// If we don't have any components for this app, ignore it (this can
|
||||
// actually happen when moving between backends but maintaining UUIDs)
|
||||
if (
|
||||
!_.isEmpty(components.services) ||
|
||||
!_.isEmpty(components.volumes) ||
|
||||
!_.isEmpty(components.networks)
|
||||
) {
|
||||
apps[appId] = new App(
|
||||
{
|
||||
appId,
|
||||
appUuid: uuid,
|
||||
commit,
|
||||
services: componentGroups[appId].services,
|
||||
networks: _.keyBy(componentGroups[appId].networks, 'name'),
|
||||
volumes: _.keyBy(componentGroups[appId].volumes, 'name'),
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return apps;
|
||||
}
|
||||
|
||||
type AppGroup = {
|
||||
[appId: number]: {
|
||||
services: Service[];
|
||||
volumes: Volume[];
|
||||
networks: Network[];
|
||||
};
|
||||
};
|
||||
|
||||
function groupComponents(
|
||||
services: Service[],
|
||||
networks: Network[],
|
||||
volumes: Volume[],
|
||||
): AppGroup {
|
||||
const grouping: AppGroup = {};
|
||||
|
||||
const everyComponent: [{ appUuid?: string; appId: number }] = [
|
||||
...services,
|
||||
...networks,
|
||||
...volumes,
|
||||
] as any;
|
||||
|
||||
const allUuids: string[] = [];
|
||||
const allAppIds: number[] = [];
|
||||
everyComponent.forEach(({ appId, appUuid }) => {
|
||||
// Pre-populate the groupings
|
||||
grouping[appId] = {
|
||||
services: [],
|
||||
networks: [],
|
||||
volumes: [],
|
||||
};
|
||||
// Save all the uuids for later
|
||||
if (appUuid != null) {
|
||||
allUuids.push(appUuid);
|
||||
}
|
||||
allAppIds.push(appId);
|
||||
});
|
||||
|
||||
// First we try to group everything by it's uuid, but if any component does
|
||||
// not have a uuid, we fall back to the old appId style
|
||||
if (everyComponent.length === allUuids.length) {
|
||||
const uuidGroups: { [uuid: string]: AppGroup[0] } = {};
|
||||
new Set(allUuids).forEach((uuid) => {
|
||||
const uuidServices = services.filter(
|
||||
({ appUuid: sUuid }) => uuid === sUuid,
|
||||
);
|
||||
const uuidVolumes = volumes.filter(
|
||||
({ appUuid: vUuid }) => uuid === vUuid,
|
||||
);
|
||||
const uuidNetworks = networks.filter(
|
||||
({ appUuid: nUuid }) => uuid === nUuid,
|
||||
);
|
||||
|
||||
uuidGroups[uuid] = {
|
||||
services: uuidServices,
|
||||
networks: uuidNetworks,
|
||||
volumes: uuidVolumes,
|
||||
};
|
||||
});
|
||||
|
||||
for (const uuid of Object.keys(uuidGroups)) {
|
||||
// There's a chance that the uuid and the appId is different, and this
|
||||
// is fine. Unfortunately we have no way of knowing which is the "real"
|
||||
// appId (that is the app id which relates to the currently joined
|
||||
// backend) so we instead just choose the first and add everything to that
|
||||
const appId =
|
||||
uuidGroups[uuid].services[0]?.appId ||
|
||||
uuidGroups[uuid].networks[0]?.appId ||
|
||||
uuidGroups[uuid].volumes[0]?.appId;
|
||||
grouping[appId] = uuidGroups[uuid];
|
||||
}
|
||||
} else {
|
||||
// Otherwise group them by appId and let the state engine match them later.
|
||||
// This will only happen once, as every target state going forward will
|
||||
// contain UUIDs, we just need to handle the initial upgrade
|
||||
const appSvcs = _.groupBy(services, 'appId');
|
||||
const appVols = _.groupBy(volumes, 'appId');
|
||||
const appNets = _.groupBy(networks, 'appId');
|
||||
|
||||
_.uniq(allAppIds).forEach((appId) => {
|
||||
grouping[appId].services = grouping[appId].services.concat(
|
||||
appSvcs[appId] || [],
|
||||
);
|
||||
grouping[appId].networks = grouping[appId].networks.concat(
|
||||
appNets[appId] || [],
|
||||
);
|
||||
grouping[appId].volumes = grouping[appId].volumes.concat(
|
||||
appVols[appId] || [],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return grouping;
|
||||
}
|
||||
|
||||
function killServicesUsingApi(current: InstancedAppState): CompositionStep[] {
|
||||
const steps: CompositionStep[] = [];
|
||||
_.each(current, (app) => {
|
||||
@ -448,7 +554,6 @@ export async function executeStep(
|
||||
// FIXME: This shouldn't be in this module
|
||||
export async function setTarget(
|
||||
apps: TargetApps,
|
||||
dependent: TargetState['dependent'],
|
||||
source: string,
|
||||
maybeTrx?: Transaction,
|
||||
) {
|
||||
@ -467,10 +572,9 @@ export async function setTarget(
|
||||
// Currently this will only happen if the release
|
||||
// which would replace it fails a contract
|
||||
// validation check
|
||||
_.map(apps, (_v, appId) => checkInt(appId)),
|
||||
Object.values(apps).map(({ id: appId }) => appId),
|
||||
)
|
||||
.del();
|
||||
await proxyvisor.setTargetInTransaction(dependent, trx);
|
||||
};
|
||||
|
||||
// We look at the container contracts here, as if we
|
||||
@ -487,18 +591,29 @@ export async function setTarget(
|
||||
const filteredApps = _.cloneDeep(apps);
|
||||
_.each(
|
||||
fulfilledContracts,
|
||||
({ valid, unmetServices, fulfilledServices, unmetAndOptional }, appId) => {
|
||||
(
|
||||
{ valid, unmetServices, fulfilledServices, unmetAndOptional },
|
||||
appUuid,
|
||||
) => {
|
||||
if (!valid) {
|
||||
contractViolators[apps[appId].name] = unmetServices;
|
||||
return delete filteredApps[appId];
|
||||
contractViolators[apps[appUuid].name] = unmetServices;
|
||||
return delete filteredApps[appUuid];
|
||||
} 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),
|
||||
);
|
||||
const [releaseUuid] = Object.keys(filteredApps[appUuid].releases);
|
||||
if (releaseUuid) {
|
||||
const services =
|
||||
filteredApps[appUuid].releases[releaseUuid].services ?? {};
|
||||
filteredApps[appUuid].releases[releaseUuid].services = _.pick(
|
||||
services,
|
||||
Object.keys(services).filter((serviceName) =>
|
||||
fulfilledServices.includes(serviceName),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (unmetAndOptional.length !== 0) {
|
||||
return reportOptionalContainers(unmetAndOptional);
|
||||
}
|
||||
@ -527,17 +642,20 @@ export async function getTargetApps(): Promise<TargetApps> {
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
});
|
||||
//
|
||||
_.each(apps, (app) =>
|
||||
// There should only be a single release but is a simpler option
|
||||
_.each(app.releases, (release) => {
|
||||
if (!_.isEmpty(release.services)) {
|
||||
release.services = _.mapValues(release.services, (svc) => {
|
||||
if (svc.image_id && targetVolatilePerImageId[svc.image_id] != null) {
|
||||
return { ...svc, ...targetVolatilePerImageId };
|
||||
}
|
||||
return svc;
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return apps;
|
||||
}
|
||||
@ -562,19 +680,28 @@ export function getDependentTargets() {
|
||||
return proxyvisor.getTarget();
|
||||
}
|
||||
|
||||
/**
|
||||
* This is only used by the API. Do not use as the use of serviceIds is getting
|
||||
* deprecated
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
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}`,
|
||||
);
|
||||
const targetApps = await getTargetApps();
|
||||
|
||||
for (const { releases } of Object.values(targetApps)) {
|
||||
const [release] = Object.values(releases);
|
||||
const services = release?.services ?? {};
|
||||
const serviceName = Object.keys(services).find(
|
||||
(svcName) => services[svcName].id === serviceId,
|
||||
);
|
||||
|
||||
if (!!serviceName) {
|
||||
return serviceName;
|
||||
}
|
||||
return service!.serviceName;
|
||||
}
|
||||
|
||||
throw new InternalInconsistencyError(
|
||||
`Could not find a service for id: ${serviceId}`,
|
||||
);
|
||||
@ -622,15 +749,6 @@ function saveAndRemoveImages(
|
||||
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
|
||||
@ -658,15 +776,16 @@ function saveAndRemoveImages(
|
||||
(svc) =>
|
||||
_.find(availableImages, {
|
||||
dockerImageId: svc.config.image,
|
||||
// There is no 1-1 mapping between services and images
|
||||
// on disk, so the only way to compare is by imageId
|
||||
imageId: svc.imageId,
|
||||
// There is no way to compare a current service to an image by
|
||||
// name, the only way to do it is by both commit and service name
|
||||
commit: svc.commit,
|
||||
serviceName: svc.serviceName,
|
||||
}) ?? _.find(availableImages, { dockerImageId: svc.config.image }),
|
||||
),
|
||||
) as imageManager.Image[];
|
||||
|
||||
const targetServices = Object.values(target).flatMap((app) => app.services);
|
||||
const targetImages = targetServices.map(imageForService);
|
||||
const targetImages = targetServices.map(imageManager.imageFromService);
|
||||
|
||||
const availableAndUnused = _.filter(
|
||||
availableWithoutIds,
|
||||
@ -735,7 +854,7 @@ function saveAndRemoveImages(
|
||||
// services
|
||||
!targetServices.some(
|
||||
(svc) =>
|
||||
imageManager.isSameImage(img, imageForService(svc)) &&
|
||||
imageManager.isSameImage(img, imageManager.imageFromService(svc)) &&
|
||||
svc.config.labels['io.balena.update.strategy'] ===
|
||||
'delete-then-download',
|
||||
),
|
||||
@ -760,14 +879,21 @@ function saveAndRemoveImages(
|
||||
.concat(imagesToRemove.map((image) => ({ action: 'removeImage', image })));
|
||||
}
|
||||
|
||||
async function getAppContainerIds(currentApps: InstancedAppState) {
|
||||
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);
|
||||
}),
|
||||
);
|
||||
Object.keys(currentApps).forEach((appId) => {
|
||||
const intAppId = parseInt(appId, 10);
|
||||
const app = currentApps[intAppId];
|
||||
const services = app.services || ([] as Service[]);
|
||||
containerIds[intAppId] = services.reduce(
|
||||
(ids, s) => ({
|
||||
...ids,
|
||||
...(s.serviceName &&
|
||||
s.containerId && { [s.serviceName]: s.containerId }),
|
||||
}),
|
||||
{} as Dictionary<string>,
|
||||
);
|
||||
});
|
||||
|
||||
return containerIds;
|
||||
}
|
||||
@ -788,12 +914,17 @@ function reportOptionalContainers(serviceNames: string[]) {
|
||||
);
|
||||
}
|
||||
|
||||
// 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() {
|
||||
/**
|
||||
* This will be replaced by ApplicationManager.getState, at which
|
||||
* point the only place this will be used will be in the API endpoints
|
||||
* once, the API moves to v3 or we update the endpoints to return uuids, we will
|
||||
* be able to get rid of this
|
||||
* @deprecated
|
||||
*/
|
||||
export async function getLegacyState() {
|
||||
const [services, images] = await Promise.all([
|
||||
serviceManager.getStatus(),
|
||||
imageManager.getStatus(),
|
||||
serviceManager.getState(),
|
||||
imageManager.getState(),
|
||||
]);
|
||||
|
||||
const apps: Dictionary<any> = {};
|
||||
@ -822,7 +953,7 @@ export async function getStatus() {
|
||||
}
|
||||
if (imageId == null) {
|
||||
throw new InternalInconsistencyError(
|
||||
`imageId not defined in ApplicationManager.getStatus: ${service}`,
|
||||
`imageId not defined in ApplicationManager.getLegacyApplicationsState: ${service}`,
|
||||
);
|
||||
}
|
||||
if (apps[appId].services[imageId] == null) {
|
||||
@ -876,3 +1007,140 @@ export async function getStatus() {
|
||||
|
||||
return { local: apps, dependent };
|
||||
}
|
||||
|
||||
// TODO: this function is probably more inefficient than it needs to be, since
|
||||
// it tried to optimize for readability, look for a way to make it simpler
|
||||
export async function getState() {
|
||||
const [services, images] = await Promise.all([
|
||||
serviceManager.getState(),
|
||||
imageManager.getState(),
|
||||
]);
|
||||
|
||||
type ServiceInfo = {
|
||||
appId: number;
|
||||
appUuid: string;
|
||||
commit: string;
|
||||
serviceName: string;
|
||||
createdAt?: Date;
|
||||
} & ServiceState;
|
||||
|
||||
// Get service data from images
|
||||
const stateFromImages: ServiceInfo[] = images.map(
|
||||
({
|
||||
appId,
|
||||
appUuid,
|
||||
name,
|
||||
commit,
|
||||
serviceName,
|
||||
status,
|
||||
downloadProgress,
|
||||
}) => ({
|
||||
appId,
|
||||
appUuid,
|
||||
image: name,
|
||||
commit,
|
||||
serviceName,
|
||||
status: status as string,
|
||||
...(downloadProgress && { download_progress: downloadProgress }),
|
||||
}),
|
||||
);
|
||||
|
||||
// Get all services and augment service data from the image if any
|
||||
const stateFromServices = services
|
||||
.map(({ appId, appUuid, commit, serviceName, status, createdAt }) => [
|
||||
// Only include appUuid if is available, if not available we'll get it from the image
|
||||
{
|
||||
appId,
|
||||
...(appUuid && { appUuid }),
|
||||
commit,
|
||||
serviceName,
|
||||
status,
|
||||
createdAt,
|
||||
},
|
||||
// Get the corresponding image to augment the service data
|
||||
stateFromImages.find(
|
||||
(img) => img.serviceName === serviceName && img.commit === commit,
|
||||
),
|
||||
])
|
||||
// We cannot report services that do not have an image as the API
|
||||
// requires passing the image name
|
||||
.filter(([, img]) => !!img)
|
||||
.map(([svc, img]) => ({ ...img, ...svc } as ServiceInfo))
|
||||
.map((svc, __, serviceList) => {
|
||||
// If the service is not running it cannot be a handover
|
||||
if (svc.status !== 'Running') {
|
||||
return svc;
|
||||
}
|
||||
|
||||
// If there one or more running services with the same name and appUuid, but different
|
||||
// release, then we are still handing over so we need to report the appropriate
|
||||
// status
|
||||
const siblings = serviceList.filter(
|
||||
(s) =>
|
||||
s.appUuid === svc.appUuid &&
|
||||
s.serviceName === svc.serviceName &&
|
||||
s.status === 'Running' &&
|
||||
s.commit !== svc.commit,
|
||||
);
|
||||
|
||||
// There should really be only one element on the `siblings` array, but
|
||||
// we chose the oldest service to have its status reported as 'Handing over'
|
||||
if (
|
||||
siblings.length > 0 &&
|
||||
siblings.every((s) => svc.createdAt!.getTime() < s.createdAt!.getTime())
|
||||
) {
|
||||
return { ...svc, status: 'Handing over' };
|
||||
} else if (siblings.length > 0) {
|
||||
return { ...svc, status: 'Awaiting handover' };
|
||||
}
|
||||
return svc;
|
||||
});
|
||||
|
||||
const servicesToReport =
|
||||
// The full list of services is the union of images that have no container created yet
|
||||
stateFromImages
|
||||
.filter(
|
||||
(img) =>
|
||||
!stateFromServices.some(
|
||||
(svc) =>
|
||||
img.serviceName === svc.serviceName && img.commit === svc.commit,
|
||||
),
|
||||
)
|
||||
// With the services that have a container
|
||||
.concat(stateFromServices);
|
||||
|
||||
// Get the list of commits for all appIds from the database
|
||||
const commitsForApp = (
|
||||
await Promise.all(
|
||||
// Deduplicate appIds first
|
||||
[...new Set(servicesToReport.map((svc) => svc.appId))].map(
|
||||
async (appId) => ({
|
||||
[appId]: await commitStore.getCommitForApp(appId),
|
||||
}),
|
||||
),
|
||||
)
|
||||
).reduce((commits, c) => ({ ...commits, ...c }), {});
|
||||
|
||||
// Assemble the state of apps
|
||||
return servicesToReport.reduce(
|
||||
(apps, { appId, appUuid, commit, serviceName, createdAt, ...svc }) => ({
|
||||
...apps,
|
||||
[appUuid]: {
|
||||
...(apps[appUuid] ?? {}),
|
||||
// Add the release_uuid if the commit has been stored in the database
|
||||
...(commitsForApp[appId] && { release_uuid: commitsForApp[appId] }),
|
||||
releases: {
|
||||
...(apps[appUuid]?.releases ?? {}),
|
||||
[commit]: {
|
||||
...(apps[appUuid]?.releases[commit] ?? {}),
|
||||
services: {
|
||||
...(apps[appUuid]?.releases[commit]?.services ?? {}),
|
||||
[serviceName]: svc,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{} as { [appUuid: string]: AppState },
|
||||
);
|
||||
}
|
||||
|
@ -2,7 +2,6 @@ import * as _ from 'lodash';
|
||||
|
||||
import * as config from '../config';
|
||||
|
||||
import * as applicationManager from './application-manager';
|
||||
import type { Image } from './images';
|
||||
import * as images from './images';
|
||||
import Network from './network';
|
||||
@ -13,7 +12,7 @@ import Volume from './volume';
|
||||
import { checkTruthy } from '../lib/validation';
|
||||
import * as networkManager from './network-manager';
|
||||
import * as volumeManager from './volume-manager';
|
||||
import { DeviceReportFields } from '../types/state';
|
||||
import { DeviceLegacyReport } from '../types/state';
|
||||
import * as commitStore from './commit';
|
||||
|
||||
interface BaseCompositionStepArgs {
|
||||
@ -57,7 +56,6 @@ interface CompositionStepArgs {
|
||||
skipLock?: boolean;
|
||||
};
|
||||
} & BaseCompositionStepArgs;
|
||||
stopAll: BaseCompositionStepArgs;
|
||||
start: {
|
||||
target: Service;
|
||||
} & BaseCompositionStepArgs;
|
||||
@ -135,7 +133,7 @@ interface CompositionCallbacks {
|
||||
fetchStart: () => void;
|
||||
fetchEnd: () => void;
|
||||
fetchTime: (time: number) => void;
|
||||
stateReport: (state: DeviceReportFields) => void;
|
||||
stateReport: (state: DeviceLegacyReport) => void;
|
||||
bestDeltaSource: (image: Image, available: Image[]) => string | null;
|
||||
}
|
||||
|
||||
@ -209,12 +207,6 @@ export function getExecutors(app: {
|
||||
},
|
||||
);
|
||||
},
|
||||
stopAll: async (step) => {
|
||||
await applicationManager.stopAll({
|
||||
force: step.force,
|
||||
skipLock: step.skipLock,
|
||||
});
|
||||
},
|
||||
start: async (step) => {
|
||||
const container = await serviceManager.start(step.target);
|
||||
app.callbacks.containerStarted(container.id);
|
||||
|
@ -29,14 +29,29 @@ interface FetchProgressEvent {
|
||||
|
||||
export interface Image {
|
||||
id?: number;
|
||||
// image registry/repo@digest or registry/repo:tag
|
||||
/**
|
||||
* image [registry/]repo@digest or [registry/]repo:tag
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @deprecated to be removed in target state v4
|
||||
*/
|
||||
appId: number;
|
||||
appUuid: string;
|
||||
/**
|
||||
* @deprecated to be removed in target state v4
|
||||
*/
|
||||
serviceId: number;
|
||||
serviceName: string;
|
||||
// Id from balena api
|
||||
/**
|
||||
* @deprecated to be removed in target state v4
|
||||
*/
|
||||
imageId: number;
|
||||
/**
|
||||
* @deprecated to be removed in target state v4
|
||||
*/
|
||||
releaseId: number;
|
||||
commit: string;
|
||||
dependent: number;
|
||||
dockerImageId?: string;
|
||||
status?: 'Downloading' | 'Downloaded' | 'Deleting';
|
||||
@ -151,17 +166,26 @@ function reportEvent(event: 'start' | 'update' | 'finish', state: Image) {
|
||||
|
||||
type ServiceInfo = Pick<
|
||||
Service,
|
||||
'imageName' | 'appId' | 'serviceId' | 'serviceName' | 'imageId' | 'releaseId'
|
||||
| 'imageName'
|
||||
| 'appId'
|
||||
| 'serviceId'
|
||||
| 'serviceName'
|
||||
| 'imageId'
|
||||
| 'releaseId'
|
||||
| 'appUuid'
|
||||
| 'commit'
|
||||
>;
|
||||
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,
|
||||
appUuid: service.appUuid!,
|
||||
serviceId: service.serviceId!,
|
||||
serviceName: service.serviceName!,
|
||||
imageId: service.imageId!,
|
||||
releaseId: service.releaseId!,
|
||||
commit: service.commit!,
|
||||
dependent: 0,
|
||||
};
|
||||
}
|
||||
@ -347,12 +371,6 @@ export async function getAvailable(): Promise<Image[]> {
|
||||
);
|
||||
}
|
||||
|
||||
export function getDownloadingImageIds(): number[] {
|
||||
return Object.values(runningTasks)
|
||||
.filter((t) => t.context.status === 'Downloading')
|
||||
.map((t) => t.context.imageId);
|
||||
}
|
||||
|
||||
export function getDownloadingImageNames(): string[] {
|
||||
return Object.values(runningTasks)
|
||||
.filter((t) => t.context.status === 'Downloading')
|
||||
@ -404,22 +422,25 @@ export async function cleanImageData(): Promise<void> {
|
||||
await db.models('image').del().whereIn('id', ids);
|
||||
}
|
||||
|
||||
export const getStatus = async () => {
|
||||
/**
|
||||
* Get the current state of all downloaded and downloading images on the device
|
||||
*/
|
||||
export const getState = async () => {
|
||||
const images = (await getAvailable()).map((img) => ({
|
||||
...img,
|
||||
status: 'Downloaded' as Image['status'],
|
||||
downloadImageSuccess: null,
|
||||
downloadProgress: null,
|
||||
}));
|
||||
|
||||
const imagesFromRunningTasks = Object.values(runningTasks).map(
|
||||
(task) => task.context,
|
||||
);
|
||||
const runningImageIds = imagesFromRunningTasks.map((img) => img.imageId);
|
||||
const runningImageNames = imagesFromRunningTasks.map((img) => img.name);
|
||||
|
||||
// TODO: this is possibly wrong, the value from getAvailable should be more reliable
|
||||
// than the value from running tasks
|
||||
return imagesFromRunningTasks.concat(
|
||||
images.filter((img) => !runningImageIds.includes(img.imageId)),
|
||||
images.filter((img) => !runningImageNames.includes(img.name)),
|
||||
);
|
||||
};
|
||||
|
||||
@ -744,6 +765,7 @@ function format(image: Image): Partial<Omit<Image, 'id'>> {
|
||||
serviceName: null,
|
||||
imageId: null,
|
||||
releaseId: null,
|
||||
commit: null,
|
||||
dependent: 0,
|
||||
dockerImageId: null,
|
||||
})
|
||||
|
@ -23,16 +23,12 @@ export function getAll(): Bluebird<Network[]> {
|
||||
});
|
||||
}
|
||||
|
||||
export function getAllByAppId(appId: number): Bluebird<Network[]> {
|
||||
return getAll().filter((network: Network) => network.appId === appId);
|
||||
}
|
||||
|
||||
export async function get(network: {
|
||||
async function get(network: {
|
||||
name: string;
|
||||
appId: number;
|
||||
appUuid: string;
|
||||
}): Promise<Network> {
|
||||
const dockerNet = await docker
|
||||
.getNetwork(Network.generateDockerName(network.appId, network.name))
|
||||
.getNetwork(Network.generateDockerName(network.appUuid, network.name))
|
||||
.inspect();
|
||||
return Network.fromDockerNetwork(dockerNet);
|
||||
}
|
||||
@ -41,7 +37,7 @@ export async function create(network: Network) {
|
||||
try {
|
||||
const existing = await get({
|
||||
name: network.name,
|
||||
appId: network.appId,
|
||||
appUuid: network.appUuid!, // new networks will always have uuid
|
||||
});
|
||||
if (!network.isEqualConfig(existing)) {
|
||||
throw new ResourceRecreationAttemptError('network', network.name);
|
||||
@ -52,7 +48,7 @@ export async function create(network: Network) {
|
||||
} catch (e) {
|
||||
if (!NotFoundError(e)) {
|
||||
logger.logSystemEvent(logTypes.createNetworkError, {
|
||||
network: { name: network.name, appId: network.appId },
|
||||
network: { name: network.name, appUuid: network.appUuid },
|
||||
error: e,
|
||||
});
|
||||
throw e;
|
||||
|
@ -1,4 +1,3 @@
|
||||
import * as Bluebird from 'bluebird';
|
||||
import * as _ from 'lodash';
|
||||
import * as dockerode from 'dockerode';
|
||||
|
||||
@ -11,29 +10,64 @@ import * as ComposeUtils from './utils';
|
||||
import { ComposeNetworkConfig, NetworkConfig } from './types/network';
|
||||
|
||||
import { InvalidNetworkNameError } from './errors';
|
||||
import { InternalInconsistencyError } from '../lib/errors';
|
||||
|
||||
export class Network {
|
||||
public appId: number;
|
||||
public appUuid?: string;
|
||||
public name: string;
|
||||
public config: NetworkConfig;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
private static deconstructDockerName(
|
||||
name: string,
|
||||
): { name: string; appId?: number; appUuid?: string } {
|
||||
const matchWithAppId = name.match(/^(\d+)_(\S+)/);
|
||||
if (matchWithAppId == null) {
|
||||
const matchWithAppUuid = name.match(/^([0-9a-f-A-F]{32,})_(\S+)/);
|
||||
|
||||
if (!matchWithAppUuid) {
|
||||
throw new InvalidNetworkNameError(name);
|
||||
}
|
||||
|
||||
const appUuid = matchWithAppUuid[1];
|
||||
return { name: matchWithAppUuid[2], appUuid };
|
||||
}
|
||||
|
||||
const appId = parseInt(matchWithAppId[1], 10);
|
||||
if (isNaN(appId)) {
|
||||
throw new InvalidNetworkNameError(name);
|
||||
}
|
||||
|
||||
return {
|
||||
appId,
|
||||
name: matchWithAppId[2],
|
||||
};
|
||||
}
|
||||
|
||||
public static fromDockerNetwork(
|
||||
network: dockerode.NetworkInspectInfo,
|
||||
): Network {
|
||||
const ret = new Network();
|
||||
|
||||
const match = network.Name.match(/^([0-9]+)_(.+)$/);
|
||||
if (match == null) {
|
||||
throw new InvalidNetworkNameError(network.Name);
|
||||
// Detect the name and appId from the inspect data
|
||||
const { name, appId, appUuid } = Network.deconstructDockerName(
|
||||
network.Name,
|
||||
);
|
||||
|
||||
const labels = network.Labels ?? {};
|
||||
if (!appId && isNaN(parseInt(labels['io.balena.app-id'], 10))) {
|
||||
// This should never happen as supervised networks will always have either
|
||||
// the id or the label
|
||||
throw new InternalInconsistencyError(
|
||||
`Could not read app id from network: ${network.Name}`,
|
||||
);
|
||||
}
|
||||
|
||||
// If the regex match succeeds `match[1]` should be a number
|
||||
const appId = parseInt(match[1], 10);
|
||||
|
||||
ret.appId = appId;
|
||||
ret.name = match[2];
|
||||
ret.appId = appId ?? parseInt(labels['io.balena.app-id'], 10);
|
||||
ret.name = name;
|
||||
ret.appUuid = appUuid;
|
||||
|
||||
const config = network.IPAM?.Config || [];
|
||||
|
||||
@ -51,7 +85,7 @@ export class Network {
|
||||
},
|
||||
enableIPv6: network.EnableIPv6,
|
||||
internal: network.Internal,
|
||||
labels: _.omit(ComposeUtils.normalizeLabels(network.Labels ?? {}), [
|
||||
labels: _.omit(ComposeUtils.normalizeLabels(labels), [
|
||||
'io.balena.supervised',
|
||||
]),
|
||||
options: network.Options ?? {},
|
||||
@ -63,6 +97,7 @@ export class Network {
|
||||
public static fromComposeObject(
|
||||
name: string,
|
||||
appId: number,
|
||||
appUuid: string,
|
||||
network: Partial<Omit<ComposeNetworkConfig, 'ipam'>> & {
|
||||
ipam?: Partial<ComposeNetworkConfig['ipam']>;
|
||||
},
|
||||
@ -70,6 +105,7 @@ export class Network {
|
||||
const net = new Network();
|
||||
net.name = name;
|
||||
net.appId = appId;
|
||||
net.appUuid = appUuid;
|
||||
|
||||
Network.validateComposeConfig(network);
|
||||
|
||||
@ -95,12 +131,13 @@ export class Network {
|
||||
},
|
||||
enableIPv6: network.enable_ipv6 || false,
|
||||
internal: network.internal || false,
|
||||
labels: network.labels || {},
|
||||
labels: {
|
||||
'io.balena.app-id': String(appId),
|
||||
...ComposeUtils.normalizeLabels(network.labels || {}),
|
||||
},
|
||||
options: network.driver_opts || {},
|
||||
};
|
||||
|
||||
net.config.labels = ComposeUtils.normalizeLabels(net.config.labels);
|
||||
|
||||
return net;
|
||||
}
|
||||
|
||||
@ -117,7 +154,7 @@ export class Network {
|
||||
|
||||
public async create(): Promise<void> {
|
||||
logger.logSystemEvent(logTypes.createNetwork, {
|
||||
network: { name: this.name },
|
||||
network: { name: this.name, appUuid: this.appUuid },
|
||||
});
|
||||
|
||||
await docker.createNetwork(this.toDockerConfig());
|
||||
@ -125,7 +162,7 @@ export class Network {
|
||||
|
||||
public toDockerConfig(): dockerode.NetworkCreateOptions {
|
||||
return {
|
||||
Name: Network.generateDockerName(this.appId, this.name),
|
||||
Name: Network.generateDockerName(this.appUuid!, this.name),
|
||||
Driver: this.config.driver,
|
||||
CheckDuplicate: true,
|
||||
Options: this.config.options,
|
||||
@ -153,28 +190,41 @@ export class Network {
|
||||
};
|
||||
}
|
||||
|
||||
public remove(): Bluebird<void> {
|
||||
public async remove() {
|
||||
logger.logSystemEvent(logTypes.removeNetwork, {
|
||||
network: { name: this.name, appId: this.appId },
|
||||
network: { name: this.name, appUuid: this.appUuid },
|
||||
});
|
||||
|
||||
const networkName = Network.generateDockerName(this.appId, this.name);
|
||||
|
||||
return Bluebird.resolve(docker.listNetworks())
|
||||
.then((networks) => networks.filter((n) => n.Name === networkName))
|
||||
.then(([network]) => {
|
||||
if (!network) {
|
||||
return Bluebird.resolve();
|
||||
// Find the network
|
||||
const [networkName] = (await docker.listNetworks())
|
||||
.filter((network) => {
|
||||
try {
|
||||
const { appId, appUuid, name } = Network.deconstructDockerName(
|
||||
network.Name,
|
||||
);
|
||||
return (
|
||||
name === this.name &&
|
||||
(appId === this.appId || appUuid === this.appUuid)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return Bluebird.resolve(
|
||||
docker.getNetwork(networkName).remove(),
|
||||
).tapCatch((error) => {
|
||||
logger.logSystemEvent(logTypes.removeNetworkError, {
|
||||
network: { name: this.name, appId: this.appId },
|
||||
error,
|
||||
});
|
||||
});
|
||||
})
|
||||
.map((network) => network.Name);
|
||||
|
||||
if (!networkName) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await docker.getNetwork(networkName).remove();
|
||||
} catch (error) {
|
||||
logger.logSystemEvent(logTypes.removeNetworkError, {
|
||||
network: { name: this.name, appUuid: this.appUuid },
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public isEqualConfig(network: Network): boolean {
|
||||
@ -210,8 +260,8 @@ export class Network {
|
||||
}
|
||||
}
|
||||
|
||||
public static generateDockerName(appId: number, name: string) {
|
||||
return `${appId}_${name}`;
|
||||
public static generateDockerName(appIdOrUuid: number | string, name: string) {
|
||||
return `${appIdOrUuid}_${name}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -84,9 +84,11 @@ export const getAll = async (
|
||||
return services.filter((s) => s != null) as Service[];
|
||||
};
|
||||
|
||||
export async function get(service: Service) {
|
||||
async function get(service: Service) {
|
||||
// Get the container ids for special network handling
|
||||
const containerIds = await getContainerIdMap(service.appId!);
|
||||
const containerIds = await getContainerIdMap(
|
||||
service.appUuid || service.appId,
|
||||
);
|
||||
const services = (
|
||||
await getAll(`service-name=${service.serviceName}`)
|
||||
).filter((currentService) =>
|
||||
@ -103,19 +105,23 @@ export async function get(service: Service) {
|
||||
return services[0];
|
||||
}
|
||||
|
||||
export async function getStatus() {
|
||||
/**
|
||||
* Get the current state of all supervised services
|
||||
*/
|
||||
export async function getState() {
|
||||
const services = await getAll();
|
||||
const status = _.clone(volatileState);
|
||||
|
||||
for (const service of services) {
|
||||
if (service.containerId == null) {
|
||||
throw new InternalInconsistencyError(
|
||||
`containerId not defined in ServiceManager.getStatus: ${service}`,
|
||||
`containerId not defined in ServiceManager.getLegacyServicesState: ${service}`,
|
||||
);
|
||||
}
|
||||
if (status[service.containerId] == null) {
|
||||
status[service.containerId] = _.pick(service, [
|
||||
'appId',
|
||||
'appUuid',
|
||||
'imageId',
|
||||
'status',
|
||||
'releaseId',
|
||||
@ -213,17 +219,8 @@ export async function remove(service: Service) {
|
||||
}
|
||||
}
|
||||
}
|
||||
export function getAllByAppId(appId: number) {
|
||||
return getAll(`app-id=${appId}`);
|
||||
}
|
||||
|
||||
export async function stopAllByAppId(appId: number) {
|
||||
for (const app of await getAllByAppId(appId)) {
|
||||
await kill(app, { removeContainer: false });
|
||||
}
|
||||
}
|
||||
|
||||
export async function create(service: Service) {
|
||||
async function create(service: Service) {
|
||||
const mockContainerId = config.newUniqueKey();
|
||||
try {
|
||||
const existing = await get(service);
|
||||
@ -251,12 +248,21 @@ export async function create(service: Service) {
|
||||
);
|
||||
}
|
||||
|
||||
// Get all created services so far
|
||||
if (service.appId == null) {
|
||||
// New services need to have an appUuid
|
||||
if (service.appUuid == null) {
|
||||
throw new InternalInconsistencyError(
|
||||
'Attempt to start a service without an existing application ID',
|
||||
'Attempt to start a service without an existing app uuid',
|
||||
);
|
||||
}
|
||||
|
||||
// We cannot get rid of appIds yet
|
||||
if (service.appId == null) {
|
||||
throw new InternalInconsistencyError(
|
||||
'Attempt to start a service without an existing app id',
|
||||
);
|
||||
}
|
||||
|
||||
// Get all created services so far, there
|
||||
const serviceContainerIds = await getContainerIdMap(service.appId);
|
||||
const conf = service.toDockerContainer({
|
||||
deviceName,
|
||||
@ -476,10 +482,16 @@ export async function attachToRunning() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getContainerIdMap(
|
||||
appId: number,
|
||||
async function getContainerIdMap(
|
||||
appIdOrUuid: number | string,
|
||||
): Promise<Dictionary<string>> {
|
||||
return _(await getAllByAppId(appId))
|
||||
const [byAppId, byAppUuid] = await Promise.all([
|
||||
getAll(`app-id=${appIdOrUuid}`),
|
||||
getAll(`app-uuid=${appIdOrUuid}`),
|
||||
]);
|
||||
|
||||
const containerList = _.unionBy(byAppId, byAppUuid, 'containerId');
|
||||
return _(containerList)
|
||||
.keyBy('serviceName')
|
||||
.mapValues('containerId')
|
||||
.value() as Dictionary<string>;
|
||||
@ -505,7 +517,15 @@ function reportNewStatus(
|
||||
containerId,
|
||||
_.merge(
|
||||
{ status },
|
||||
_.pick(service, ['imageId', 'appId', 'releaseId', 'commit']),
|
||||
_.pick(service, [
|
||||
'imageId',
|
||||
'appId',
|
||||
'appUuid',
|
||||
'serviceName',
|
||||
'releaseId',
|
||||
'createdAt',
|
||||
'commit',
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -44,6 +44,7 @@ export type ServiceStatus =
|
||||
|
||||
export class Service {
|
||||
public appId: number;
|
||||
public appUuid?: string;
|
||||
public imageId: number;
|
||||
public config: ServiceConfig;
|
||||
public serviceName: string;
|
||||
@ -111,7 +112,10 @@ export class Service {
|
||||
): Promise<Service> {
|
||||
const service = new Service();
|
||||
|
||||
appConfig = ComposeUtils.camelCaseConfig(appConfig);
|
||||
appConfig = {
|
||||
...appConfig,
|
||||
composition: ComposeUtils.camelCaseConfig(appConfig.composition || {}),
|
||||
};
|
||||
|
||||
if (!appConfig.appId) {
|
||||
throw new InternalInconsistencyError('No app id for service');
|
||||
@ -124,27 +128,33 @@ export class Service {
|
||||
// Separate the application information from the docker
|
||||
// container configuration
|
||||
service.imageId = parseInt(appConfig.imageId, 10);
|
||||
delete appConfig.imageId;
|
||||
service.serviceName = appConfig.serviceName;
|
||||
delete appConfig.serviceName;
|
||||
service.appId = appId;
|
||||
delete appConfig.appId;
|
||||
service.releaseId = parseInt(appConfig.releaseId, 10);
|
||||
delete appConfig.releaseId;
|
||||
service.serviceId = parseInt(appConfig.serviceId, 10);
|
||||
delete appConfig.serviceId;
|
||||
service.imageName = appConfig.image;
|
||||
service.dependsOn = appConfig.dependsOn || null;
|
||||
delete appConfig.dependsOn;
|
||||
service.createdAt = appConfig.createdAt;
|
||||
delete appConfig.createdAt;
|
||||
service.commit = appConfig.commit;
|
||||
delete appConfig.commit;
|
||||
service.appUuid = appConfig.appUuid;
|
||||
|
||||
delete appConfig.contract;
|
||||
// dependsOn is used by other parts of the step
|
||||
// calculation so we delete it from the composition
|
||||
service.dependsOn = appConfig.composition?.dependsOn || null;
|
||||
delete appConfig.composition?.dependsOn;
|
||||
|
||||
// Get remaining fields from appConfig
|
||||
const { image, running, labels, environment, composition } = appConfig;
|
||||
|
||||
// Get rid of any extra values and report them to the user
|
||||
const config = sanitiseComposeConfig(appConfig);
|
||||
const config = sanitiseComposeConfig({
|
||||
image,
|
||||
running,
|
||||
...composition,
|
||||
|
||||
// Ensure the top level label and environment definition is used
|
||||
labels: { ...(composition?.labels ?? {}), ...labels },
|
||||
environment: { ...(composition?.environment ?? {}), ...environment },
|
||||
});
|
||||
|
||||
// Process some values into the correct format, delete them from
|
||||
// the original object, and add them to the defaults object below
|
||||
@ -161,7 +171,7 @@ export class Service {
|
||||
networks = config.networks || {};
|
||||
}
|
||||
// Prefix the network entries with the app id
|
||||
networks = _.mapKeys(networks, (_v, k) => `${service.appId}_${k}`);
|
||||
networks = _.mapKeys(networks, (_v, k) => `${service.appUuid}_${k}`);
|
||||
// Ensure that we add an alias of the service name
|
||||
networks = _.mapValues(networks, (v) => {
|
||||
if (v.aliases == null) {
|
||||
@ -247,7 +257,7 @@ export class Service {
|
||||
) {
|
||||
if (networks[config.networkMode!] == null && !serviceNetworkMode) {
|
||||
// The network mode has not been set explicitly
|
||||
config.networkMode = `${service.appId}_${config.networkMode}`;
|
||||
config.networkMode = `${service.appUuid}_${config.networkMode}`;
|
||||
// If we don't have any networks, we need to
|
||||
// create the default with some default options
|
||||
networks[config.networkMode] = {
|
||||
@ -265,6 +275,7 @@ export class Service {
|
||||
config.environment || {},
|
||||
options,
|
||||
service.appId || 0,
|
||||
service.appUuid!,
|
||||
service.serviceName || '',
|
||||
),
|
||||
);
|
||||
@ -275,6 +286,7 @@ export class Service {
|
||||
service.appId || 0,
|
||||
service.serviceId || 0,
|
||||
service.serviceName || '',
|
||||
service.appUuid!, // appUuid will always exist on the target state
|
||||
),
|
||||
);
|
||||
|
||||
@ -614,6 +626,7 @@ export class Service {
|
||||
);
|
||||
}
|
||||
svc.appId = appId;
|
||||
svc.appUuid = svc.config.labels['io.balena.app-uuid'];
|
||||
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)) {
|
||||
@ -957,6 +970,7 @@ export class Service {
|
||||
environment: { [envVarName: string]: string } | null | undefined,
|
||||
options: DeviceMetadata,
|
||||
appId: number,
|
||||
appUuid: string,
|
||||
serviceName: string,
|
||||
): { [envVarName: string]: string } {
|
||||
const defaultEnv: { [envVarName: string]: string } = {};
|
||||
@ -966,6 +980,7 @@ export class Service {
|
||||
_.mapKeys(
|
||||
{
|
||||
APP_ID: appId.toString(),
|
||||
APP_UUID: appUuid,
|
||||
APP_NAME: options.appName,
|
||||
SERVICE_NAME: serviceName,
|
||||
DEVICE_UUID: options.uuid,
|
||||
@ -993,11 +1008,23 @@ export class Service {
|
||||
public hasNetwork(networkName: string) {
|
||||
// TODO; we could probably export network naming methods to another
|
||||
// module to avoid duplicate code
|
||||
return `${this.appId}_${networkName}` in this.config.networks;
|
||||
// We don't know if this service is current or target state so we need
|
||||
// to check both appId and appUuid since the current service may still
|
||||
// have appId
|
||||
return (
|
||||
`${this.appUuid}_${networkName}` in this.config.networks ||
|
||||
`${this.appId}_${networkName}` in this.config.networks
|
||||
);
|
||||
}
|
||||
|
||||
public hasNetworkMode(networkName: string) {
|
||||
return `${this.appId}_${networkName}` === this.config.networkMode;
|
||||
// We don't know if this service is current or target state so we need
|
||||
// to check both appId and appUuid since the current service may still
|
||||
// have appId
|
||||
return (
|
||||
`${this.appUuid}_${networkName}` === this.config.networkMode ||
|
||||
`${this.appId}_${networkName}` === this.config.networkMode
|
||||
);
|
||||
}
|
||||
|
||||
public hasVolume(volumeName: string) {
|
||||
@ -1071,13 +1098,18 @@ export class Service {
|
||||
appId: number,
|
||||
serviceId: number,
|
||||
serviceName: string,
|
||||
appUuid: string,
|
||||
): { [labelName: string]: string } {
|
||||
let newLabels = _.defaults(labels, {
|
||||
'io.balena.supervised': 'true',
|
||||
'io.balena.app-id': appId.toString(),
|
||||
'io.balena.service-id': serviceId.toString(),
|
||||
'io.balena.service-name': serviceName,
|
||||
});
|
||||
let newLabels = {
|
||||
...labels,
|
||||
...{
|
||||
'io.balena.supervised': 'true',
|
||||
'io.balena.app-id': appId.toString(),
|
||||
'io.balena.service-id': serviceId.toString(),
|
||||
'io.balena.service-name': serviceName,
|
||||
'io.balena.app-uuid': appUuid,
|
||||
},
|
||||
};
|
||||
|
||||
const imageLabels = _.get(imageInfo, 'Config.Labels', {});
|
||||
newLabels = _.defaults(newLabels, imageLabels);
|
||||
|
@ -7,7 +7,6 @@ import { NotFoundError, InternalInconsistencyError } from '../lib/errors';
|
||||
import { safeRename } from '../lib/fs-utils';
|
||||
import { docker } from '../lib/docker-utils';
|
||||
import * as LogTypes from '../lib/log-types';
|
||||
import { defaultLegacyVolume } from '../lib/migration';
|
||||
import log from '../lib/supervisor-console';
|
||||
import * as logger from '../logger';
|
||||
import { ResourceRecreationAttemptError } from './errors';
|
||||
@ -78,31 +77,20 @@ export async function remove(volume: Volume) {
|
||||
await volume.remove();
|
||||
}
|
||||
|
||||
export async function createFromLegacy(appId: number): Promise<Volume | void> {
|
||||
const name = defaultLegacyVolume();
|
||||
const legacyPath = Path.join(
|
||||
constants.rootMountPoint,
|
||||
'mnt/data/resin-data',
|
||||
appId.toString(),
|
||||
);
|
||||
|
||||
try {
|
||||
return await createFromPath({ name, appId }, {}, legacyPath);
|
||||
} catch (e) {
|
||||
logger.logSystemMessage(
|
||||
`Warning: could not migrate legacy /data volume: ${e.message}`,
|
||||
{ error: e },
|
||||
'Volume migration error',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createFromPath(
|
||||
{ name, appId }: VolumeNameOpts,
|
||||
{ name, appId, appUuid }: VolumeNameOpts & { appUuid?: string },
|
||||
config: Partial<VolumeConfig>,
|
||||
oldPath: string,
|
||||
): Promise<Volume> {
|
||||
const volume = Volume.fromComposeObject(name, appId, config);
|
||||
const volume = Volume.fromComposeObject(
|
||||
name,
|
||||
appId,
|
||||
// We may not have a uuid here, but we need one to create a volume
|
||||
// from a compose object. We pass uuid as undefined here so that we will
|
||||
// fallback to id comparison for apps
|
||||
appUuid as any,
|
||||
config,
|
||||
);
|
||||
|
||||
await create(volume);
|
||||
const inspect = await docker
|
||||
|
@ -1,5 +1,4 @@
|
||||
import * as Docker from 'dockerode';
|
||||
import assign = require('lodash/assign');
|
||||
import isEqual = require('lodash/isEqual');
|
||||
import omitBy = require('lodash/omitBy');
|
||||
|
||||
@ -27,6 +26,7 @@ export class Volume {
|
||||
private constructor(
|
||||
public name: string,
|
||||
public appId: number,
|
||||
public appUuid: string,
|
||||
public config: VolumeConfig,
|
||||
) {}
|
||||
|
||||
@ -40,26 +40,34 @@ export class Volume {
|
||||
|
||||
// Detect the name and appId from the inspect data
|
||||
const { name, appId } = this.deconstructDockerName(inspect.Name);
|
||||
const appUuid = config.labels['io.balena.app-uuid'];
|
||||
|
||||
return new Volume(name, appId, config);
|
||||
return new Volume(name, appId, appUuid, config);
|
||||
}
|
||||
|
||||
public static fromComposeObject(
|
||||
name: string,
|
||||
appId: number,
|
||||
config: Partial<ComposeVolumeConfig>,
|
||||
appUuid: string,
|
||||
config = {} as Partial<ComposeVolumeConfig>,
|
||||
) {
|
||||
const filledConfig: VolumeConfig = {
|
||||
driverOpts: config.driver_opts || {},
|
||||
driver: config.driver || 'local',
|
||||
labels: ComposeUtils.normalizeLabels(config.labels || {}),
|
||||
labels: {
|
||||
// We only need to assign the labels here, as when we
|
||||
// get it from the daemon, they should already be there
|
||||
...ComposeUtils.normalizeLabels(config.labels || {}),
|
||||
...constants.defaultVolumeLabels,
|
||||
|
||||
// the app uuid will always be in the target state, the
|
||||
// only reason this is done this way is to be compatible
|
||||
// with loading a volume from backup (see lib/migration)
|
||||
...(appUuid && { 'io.balena.app-uuid': appUuid }),
|
||||
},
|
||||
};
|
||||
|
||||
// We only need to assign the labels here, as when we
|
||||
// get it from the daemon, they should already be there
|
||||
assign(filledConfig.labels, constants.defaultVolumeLabels);
|
||||
|
||||
return new Volume(name, appId, filledConfig);
|
||||
return new Volume(name, appId, appUuid, filledConfig);
|
||||
}
|
||||
|
||||
public toComposeObject(): ComposeVolumeConfig {
|
||||
@ -141,7 +149,14 @@ export class Volume {
|
||||
// TODO: Export these to a constant
|
||||
return omitBy(
|
||||
labels,
|
||||
(_v, k) => k === 'io.resin.supervised' || k === 'io.balena.supervised',
|
||||
(_v, k) =>
|
||||
k === 'io.resin.supervised' ||
|
||||
k === 'io.balena.supervised' ||
|
||||
// TODO: we need to omit the app-uuid label
|
||||
// in the comparison or else the supervisor will try to recreate
|
||||
// the volume, which won't fail but won't have any effect on the volume
|
||||
// either, leading to a supervisor target state apply loop
|
||||
k === 'io.balena.app-uuid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -32,6 +32,7 @@ import { checkInt, checkTruthy } from '../lib/validation';
|
||||
import { isVPNActive } from '../network';
|
||||
import { doPurge, doRestart, safeStateClone } from './common';
|
||||
import { AuthorizedRequest } from '../lib/api-keys';
|
||||
import { fromV2TargetState } from '../lib/legacy';
|
||||
|
||||
export function createV2Api(router: Router) {
|
||||
const handleServiceAction = (
|
||||
@ -191,8 +192,8 @@ export function createV2Api(router: Router) {
|
||||
// It's kinda hacky to access the services and db via the application manager
|
||||
// maybe refactor this code
|
||||
Bluebird.join(
|
||||
serviceManager.getStatus(),
|
||||
images.getStatus(),
|
||||
serviceManager.getState(),
|
||||
images.getState(),
|
||||
db.models('app').select(['appId', 'commit', 'name']),
|
||||
(
|
||||
services,
|
||||
@ -284,7 +285,7 @@ export function createV2Api(router: Router) {
|
||||
// Query device for all applications
|
||||
let apps: any;
|
||||
try {
|
||||
apps = await applicationManager.getStatus();
|
||||
apps = await applicationManager.getLegacyState();
|
||||
} catch (e) {
|
||||
log.error(e.message);
|
||||
return res.status(500).json({
|
||||
@ -346,7 +347,10 @@ export function createV2Api(router: Router) {
|
||||
|
||||
// Now attempt to set the state
|
||||
const force = req.body.force;
|
||||
const targetState = req.body;
|
||||
|
||||
// Migrate target state from v2 to v3 to maintain API compatibility
|
||||
const targetState = await fromV2TargetState(req.body, true);
|
||||
|
||||
try {
|
||||
await deviceState.setTarget(targetState, true);
|
||||
await deviceState.triggerApplyTarget({ force });
|
||||
@ -472,7 +476,7 @@ export function createV2Api(router: Router) {
|
||||
|
||||
let downloadProgressTotal = 0;
|
||||
let downloads = 0;
|
||||
const imagesStates = (await images.getStatus())
|
||||
const imagesStates = (await images.getState())
|
||||
.filter((img) => req.auth.isScoped({ apps: [img.appId] }))
|
||||
.map((img) => {
|
||||
appIds.push(img.appId);
|
||||
@ -557,19 +561,17 @@ export function createV2Api(router: Router) {
|
||||
|
||||
router.get('/v2/cleanup-volumes', async (req: AuthorizedRequest, res) => {
|
||||
const targetState = await applicationManager.getTargetApps();
|
||||
const referencedVolumes: string[] = [];
|
||||
_.each(targetState, (app, appId) => {
|
||||
const referencedVolumes = Object.values(targetState)
|
||||
// if this app isn't in scope of the request, do not cleanup it's volumes
|
||||
if (!req.auth.isScoped({ apps: [parseInt(appId, 10)] })) {
|
||||
return;
|
||||
}
|
||||
|
||||
_.each(app.volumes, (_volume, volumeName) => {
|
||||
referencedVolumes.push(
|
||||
Volume.generateDockerName(parseInt(appId, 10), volumeName),
|
||||
.filter((app) => req.auth.isScoped({ apps: [app.id] }))
|
||||
.flatMap((app) => {
|
||||
const [release] = Object.values(app.releases);
|
||||
// Return a list of the volume names
|
||||
return Object.keys(release?.volumes ?? {}).map((volumeName) =>
|
||||
Volume.generateDockerName(app.id, volumeName),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
await volumeManager.removeOrphanedVolumes(referencedVolumes);
|
||||
res.json({
|
||||
status: 'success',
|
||||
|
@ -10,7 +10,6 @@ import { EnvVarObject } from './types';
|
||||
import { UnitNotLoadedError } from './lib/errors';
|
||||
import { checkInt, checkTruthy } from './lib/validation';
|
||||
import log from './lib/supervisor-console';
|
||||
import { DeviceStatus } from './types/state';
|
||||
import * as configUtils from './config/utils';
|
||||
import { SchemaTypeKey } from './config/schema-type';
|
||||
import { matchesAnyBootConfig } from './config/backends';
|
||||
@ -560,19 +559,11 @@ async function isRebootRequired() {
|
||||
}
|
||||
|
||||
export async function getRequiredSteps(
|
||||
currentState: DeviceStatus,
|
||||
targetState: { local?: { config?: Dictionary<string> } },
|
||||
currentState: { local?: { config?: EnvVarObject } },
|
||||
targetState: { local?: { config: EnvVarObject } },
|
||||
): Promise<ConfigStep[]> {
|
||||
const current: Dictionary<string> = _.get(
|
||||
currentState,
|
||||
['local', 'config'],
|
||||
{},
|
||||
);
|
||||
const target: Dictionary<string> = _.get(
|
||||
targetState,
|
||||
['local', 'config'],
|
||||
{},
|
||||
);
|
||||
const current = currentState?.local?.config ?? {};
|
||||
const target = targetState?.local?.config ?? {};
|
||||
|
||||
const configSteps = getConfigSteps(current, target);
|
||||
const steps = [
|
||||
|
@ -37,14 +37,16 @@ import * as deviceConfig from './device-config';
|
||||
import { ConfigStep } from './device-config';
|
||||
import { log } from './lib/supervisor-console';
|
||||
import {
|
||||
DeviceReportFields,
|
||||
DeviceStatus,
|
||||
DeviceLegacyState,
|
||||
InstancedDeviceState,
|
||||
TargetState,
|
||||
InstancedAppState,
|
||||
} from './types/state';
|
||||
DeviceState,
|
||||
DeviceReport,
|
||||
AppState,
|
||||
} from './types';
|
||||
import * as dbFormat from './device-state/db-format';
|
||||
import * as apiKeys from './lib/api-keys';
|
||||
import * as sysInfo from './lib/system-info';
|
||||
|
||||
const disallowedHostConfigPatchFields = ['local_ip', 'local_port'];
|
||||
|
||||
@ -166,7 +168,7 @@ function createDeviceStateRouter() {
|
||||
|
||||
router.get('/v1/device', async (_req, res) => {
|
||||
try {
|
||||
const state = await getStatus();
|
||||
const state = await getLegacyState();
|
||||
const stateToSend = _.pick(state.local, [
|
||||
'api_port',
|
||||
'ip_address',
|
||||
@ -248,7 +250,7 @@ type DeviceStateStep<T extends PossibleStepTargets> =
|
||||
| CompositionStepT<T extends CompositionStepAction ? T : never>
|
||||
| ConfigStep;
|
||||
|
||||
let currentVolatile: DeviceReportFields = {};
|
||||
let currentVolatile: DeviceReport = {};
|
||||
const writeLock = updateLock.writeLock;
|
||||
const readLock = updateLock.readLock;
|
||||
let maxPollTime: number;
|
||||
@ -355,7 +357,7 @@ export async function initNetworkChecks({
|
||||
});
|
||||
log.debug('Starting periodic check for IP addresses');
|
||||
|
||||
await network.startIPAddressUpdate()(async (addresses) => {
|
||||
network.startIPAddressUpdate()(async (addresses) => {
|
||||
const macAddress = await config.get('macAddress');
|
||||
reportCurrentState({
|
||||
ip_address: addresses.join(' '),
|
||||
@ -479,27 +481,29 @@ export async function setTarget(target: TargetState, localSource?: boolean) {
|
||||
|
||||
globalEventBus.getInstance().emit('targetStateChanged', target);
|
||||
|
||||
const apiEndpoint = await config.get('apiEndpoint');
|
||||
const { uuid, apiEndpoint } = await config.getMany([
|
||||
'uuid',
|
||||
'apiEndpoint',
|
||||
'name',
|
||||
]);
|
||||
|
||||
if (!uuid || !target[uuid]) {
|
||||
throw new Error(
|
||||
`Expected target state for local device with uuid '${uuid}'.`,
|
||||
);
|
||||
}
|
||||
|
||||
const localTarget = target[uuid];
|
||||
|
||||
await usingWriteLockTarget(async () => {
|
||||
await db.transaction(async (trx) => {
|
||||
await config.set({ name: target.local.name }, trx);
|
||||
await deviceConfig.setTarget(target.local.config, trx);
|
||||
await config.set({ name: localTarget.name }, trx);
|
||||
await deviceConfig.setTarget(localTarget.config, trx);
|
||||
|
||||
if (localSource || apiEndpoint == null || apiEndpoint === '') {
|
||||
await applicationManager.setTarget(
|
||||
target.local.apps,
|
||||
target.dependent,
|
||||
'local',
|
||||
trx,
|
||||
);
|
||||
await applicationManager.setTarget(localTarget.apps, 'local', trx);
|
||||
} else {
|
||||
await applicationManager.setTarget(
|
||||
target.local.apps,
|
||||
target.dependent,
|
||||
apiEndpoint,
|
||||
trx,
|
||||
);
|
||||
await applicationManager.setTarget(localTarget.apps, apiEndpoint, trx);
|
||||
}
|
||||
await config.set({ targetStateSet: true }, trx);
|
||||
});
|
||||
@ -528,9 +532,14 @@ export function getTarget({
|
||||
});
|
||||
}
|
||||
|
||||
export async function getStatus(): Promise<DeviceStatus> {
|
||||
const appsStatus = await applicationManager.getStatus();
|
||||
const theState: DeepPartial<DeviceStatus> = {
|
||||
// This returns the current state of the device in (more or less)
|
||||
// the same format as the target state. This method,
|
||||
// getCurrent and getCurrentForComparison should probably get
|
||||
// merged into a single method
|
||||
// @deprecated
|
||||
export async function getLegacyState(): Promise<DeviceLegacyState> {
|
||||
const appsStatus = await applicationManager.getLegacyState();
|
||||
const theState: DeepPartial<DeviceLegacyState> = {
|
||||
local: {},
|
||||
dependent: {},
|
||||
};
|
||||
@ -560,29 +569,95 @@ export async function getStatus(): Promise<DeviceStatus> {
|
||||
}
|
||||
}
|
||||
|
||||
return theState as DeviceStatus;
|
||||
return theState as DeviceLegacyState;
|
||||
}
|
||||
|
||||
export async function getCurrentForComparison(): Promise<
|
||||
DeviceStatus & { local: { name: string } }
|
||||
> {
|
||||
const [name, devConfig, apps, dependent] = await Promise.all([
|
||||
config.get('name'),
|
||||
deviceConfig.getCurrent(),
|
||||
applicationManager.getCurrentAppsForReport(),
|
||||
applicationManager.getDependentState(),
|
||||
]);
|
||||
return {
|
||||
local: {
|
||||
name,
|
||||
config: devConfig,
|
||||
apps,
|
||||
},
|
||||
async function getSysInfo(
|
||||
lastInfo: Partial<sysInfo.SystemInfo>,
|
||||
): Promise<sysInfo.SystemInfo> {
|
||||
// If hardwareMetrics is false, send null patch for system metrics to cloud API
|
||||
const currentInfo = {
|
||||
...((await config.get('hardwareMetrics'))
|
||||
? await sysInfo.getSystemMetrics()
|
||||
: {
|
||||
cpu_usage: null,
|
||||
memory_usage: null,
|
||||
memory_total: null,
|
||||
storage_usage: null,
|
||||
storage_total: null,
|
||||
storage_block_device: null,
|
||||
cpu_temp: null,
|
||||
cpu_id: null,
|
||||
}),
|
||||
...(await sysInfo.getSystemChecks()),
|
||||
};
|
||||
|
||||
dependent,
|
||||
return Object.assign(
|
||||
{} as sysInfo.SystemInfo,
|
||||
...Object.keys(currentInfo).map((key: keyof sysInfo.SystemInfo) => ({
|
||||
[key]: sysInfo.isSignificantChange(
|
||||
key,
|
||||
lastInfo[key] as number,
|
||||
currentInfo[key] as number,
|
||||
)
|
||||
? (currentInfo[key] as number)
|
||||
: (lastInfo[key] as number),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// Return current state in a way that the API understands
|
||||
export async function getCurrentForReport(
|
||||
lastReport = {} as DeviceState,
|
||||
): Promise<DeviceState> {
|
||||
const apps = await applicationManager.getState();
|
||||
|
||||
// Fiter current apps by the target state as the supervisor cannot
|
||||
// report on apps for which it doesn't have API permissions
|
||||
const targetAppUuids = Object.keys(await applicationManager.getTargetApps());
|
||||
const appsForReport = Object.keys(apps)
|
||||
.filter((appUuid) => targetAppUuids.includes(appUuid))
|
||||
.reduce(
|
||||
(filteredApps, appUuid) => ({
|
||||
...filteredApps,
|
||||
[appUuid]: apps[appUuid],
|
||||
}),
|
||||
{} as { [appUuid: string]: AppState },
|
||||
);
|
||||
|
||||
const { name, uuid, localMode } = await config.getMany([
|
||||
'name',
|
||||
'uuid',
|
||||
'localMode',
|
||||
]);
|
||||
|
||||
if (!uuid) {
|
||||
throw new InternalInconsistencyError('No uuid found for local device');
|
||||
}
|
||||
|
||||
const omitFromReport = [
|
||||
'update_pending',
|
||||
'update_downloaded',
|
||||
'update_failed',
|
||||
...(localMode ? ['apps', 'logs_channel'] : []),
|
||||
];
|
||||
|
||||
const systemInfo = await getSysInfo(lastReport[uuid] ?? {});
|
||||
|
||||
return {
|
||||
[uuid]: _.omitBy(
|
||||
{
|
||||
...currentVolatile,
|
||||
...systemInfo,
|
||||
name,
|
||||
apps: appsForReport,
|
||||
},
|
||||
(__, key) => omitFromReport.includes(key),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Get the current state as object instances
|
||||
export async function getCurrentState(): Promise<InstancedDeviceState> {
|
||||
const [name, devConfig, apps, dependent] = await Promise.all([
|
||||
config.get('name'),
|
||||
@ -601,9 +676,7 @@ export async function getCurrentState(): Promise<InstancedDeviceState> {
|
||||
};
|
||||
}
|
||||
|
||||
export function reportCurrentState(
|
||||
newState: DeviceReportFields & Partial<InstancedAppState> = {},
|
||||
) {
|
||||
export function reportCurrentState(newState: DeviceReport = {}) {
|
||||
if (newState == null) {
|
||||
newState = {};
|
||||
}
|
||||
@ -762,7 +835,7 @@ export const applyTarget = async ({
|
||||
|
||||
return usingInferStepsLock(async () => {
|
||||
const [currentState, targetState] = await Promise.all([
|
||||
getCurrentForComparison(),
|
||||
getCurrentState(),
|
||||
getTarget({ initial, intermediate }),
|
||||
]);
|
||||
const deviceConfigSteps = await deviceConfig.getRequiredSteps(
|
||||
@ -782,6 +855,7 @@ export const applyTarget = async ({
|
||||
steps = deviceConfigSteps;
|
||||
} else {
|
||||
const appSteps = await applicationManager.getRequiredSteps(
|
||||
currentState.local.apps,
|
||||
targetState.local.apps,
|
||||
);
|
||||
|
||||
|
@ -1,258 +0,0 @@
|
||||
import * as _ from 'lodash';
|
||||
import * as url from 'url';
|
||||
import { CoreOptions } from 'request';
|
||||
|
||||
import * as constants from '../lib/constants';
|
||||
import { withBackoff, OnFailureInfo } from '../lib/backoff';
|
||||
import { log } from '../lib/supervisor-console';
|
||||
import { InternalInconsistencyError, StatusError } from '../lib/errors';
|
||||
import { getRequestInstance } from '../lib/request';
|
||||
import * as sysInfo from '../lib/system-info';
|
||||
|
||||
import { DeviceStatus } from '../types/state';
|
||||
import * as config from '../config';
|
||||
import { SchemaTypeKey, SchemaReturn } from '../config/schema-type';
|
||||
import * as eventTracker from '../event-tracker';
|
||||
import * as deviceState from '../device-state';
|
||||
|
||||
const INTERNAL_STATE_KEYS = [
|
||||
'update_pending',
|
||||
'update_downloaded',
|
||||
'update_failed',
|
||||
];
|
||||
|
||||
export let stateReportErrors = 0;
|
||||
const lastReportedState: DeviceStatus = {
|
||||
local: {},
|
||||
dependent: {},
|
||||
};
|
||||
let reportPending = false;
|
||||
|
||||
type CurrentStateReportConf = {
|
||||
[key in keyof Pick<
|
||||
config.ConfigMap<SchemaTypeKey>,
|
||||
| 'uuid'
|
||||
| 'apiEndpoint'
|
||||
| 'apiTimeout'
|
||||
| 'deviceApiKey'
|
||||
| 'deviceId'
|
||||
| 'localMode'
|
||||
| 'appUpdatePollInterval'
|
||||
| 'hardwareMetrics'
|
||||
>]: SchemaReturn<key>;
|
||||
};
|
||||
|
||||
type StateReport = {
|
||||
stateDiff: DeviceStatus;
|
||||
conf: Omit<CurrentStateReportConf, 'deviceId' | 'hardwareMetrics'>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns an object that contains only status fields relevant for the local mode.
|
||||
* It basically removes information about applications state.
|
||||
*/
|
||||
const stripDeviceStateInLocalMode = (state: DeviceStatus): DeviceStatus => {
|
||||
return {
|
||||
local: _.cloneDeep(
|
||||
_.omit(state.local, 'apps', 'is_on__commit', 'logs_channel'),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
async function report({ stateDiff, conf }: StateReport): Promise<boolean> {
|
||||
let body = stateDiff;
|
||||
const { apiEndpoint, apiTimeout, deviceApiKey, localMode, uuid } = conf;
|
||||
if (localMode) {
|
||||
body = stripDeviceStateInLocalMode(stateDiff);
|
||||
}
|
||||
|
||||
if (_.isEmpty(body.local)) {
|
||||
// Nothing to send.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (conf.uuid == null || conf.apiEndpoint == null) {
|
||||
throw new InternalInconsistencyError(
|
||||
'No uuid or apiEndpoint provided to CurrentState.report',
|
||||
);
|
||||
}
|
||||
|
||||
const endpoint = url.resolve(apiEndpoint, `/device/v2/${uuid}/state`);
|
||||
const request = await getRequestInstance();
|
||||
|
||||
const params: CoreOptions = {
|
||||
json: true,
|
||||
headers: {
|
||||
Authorization: `Bearer ${deviceApiKey}`,
|
||||
},
|
||||
body,
|
||||
};
|
||||
|
||||
const [
|
||||
{ statusCode, body: statusMessage, headers },
|
||||
] = await request.patchAsync(endpoint, params).timeout(apiTimeout);
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
throw new StatusError(
|
||||
statusCode,
|
||||
JSON.stringify(statusMessage, null, 2),
|
||||
headers['retry-after'] ? parseInt(headers['retry-after'], 10) : undefined,
|
||||
);
|
||||
}
|
||||
// State was reported
|
||||
return true;
|
||||
}
|
||||
|
||||
function newStateDiff(stateForReport: DeviceStatus): DeviceStatus {
|
||||
const lastReportedLocal = lastReportedState.local;
|
||||
const lastReportedDependent = lastReportedState.dependent;
|
||||
if (lastReportedLocal == null || lastReportedDependent == null) {
|
||||
throw new InternalInconsistencyError(
|
||||
`No local or dependent component of lastReportedLocal in CurrentState.getStateDiff: ${JSON.stringify(
|
||||
lastReportedState,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const diff = {
|
||||
local: _.omitBy(
|
||||
stateForReport.local,
|
||||
(val, key: keyof NonNullable<DeviceStatus['local']>) =>
|
||||
INTERNAL_STATE_KEYS.includes(key) ||
|
||||
_.isEqual(lastReportedLocal[key], val) ||
|
||||
!sysInfo.isSignificantChange(
|
||||
key,
|
||||
lastReportedLocal[key] as number,
|
||||
val as number,
|
||||
),
|
||||
),
|
||||
dependent: _.omitBy(
|
||||
stateForReport.dependent,
|
||||
(val, key: keyof DeviceStatus['dependent']) =>
|
||||
INTERNAL_STATE_KEYS.includes(key) ||
|
||||
_.isEqual(lastReportedDependent[key], val),
|
||||
),
|
||||
};
|
||||
|
||||
return _.omitBy(diff, _.isEmpty);
|
||||
}
|
||||
|
||||
async function reportCurrentState(conf: CurrentStateReportConf) {
|
||||
// Ensure no other report starts
|
||||
reportPending = true;
|
||||
// Wrap the report with fetching of state so report always has the latest state diff
|
||||
const getStateAndReport = async () => {
|
||||
// Get state to report
|
||||
const stateToReport = await generateStateForReport();
|
||||
// Get diff from last reported state
|
||||
const stateDiff = newStateDiff(stateToReport);
|
||||
// Report diff
|
||||
if (await report({ stateDiff, conf })) {
|
||||
// Update lastReportedState
|
||||
_.assign(lastReportedState.local, stateDiff.local);
|
||||
_.assign(lastReportedState.dependent, stateDiff.dependent);
|
||||
// Log that we successfully reported the current state
|
||||
log.info('Reported current state to the cloud');
|
||||
}
|
||||
};
|
||||
// Create a report that will backoff on errors
|
||||
const reportWithBackoff = withBackoff(getStateAndReport, {
|
||||
maxDelay: conf.appUpdatePollInterval,
|
||||
minDelay: 15000,
|
||||
onFailure: handleRetry,
|
||||
});
|
||||
// Run in try block to avoid throwing any exceptions
|
||||
try {
|
||||
await reportWithBackoff();
|
||||
stateReportErrors = 0;
|
||||
} catch (e) {
|
||||
log.error(e);
|
||||
}
|
||||
reportPending = false;
|
||||
}
|
||||
|
||||
function handleRetry(retryInfo: OnFailureInfo) {
|
||||
if (retryInfo.error instanceof StatusError) {
|
||||
// We don't want these errors to be classed as a report error, as this will cause
|
||||
// the watchdog to kill the supervisor - and killing the supervisor will
|
||||
// not help in this situation
|
||||
log.error(
|
||||
`Device state report failure! Status code: ${retryInfo.error.statusCode} - message:`,
|
||||
retryInfo.error?.message ?? retryInfo.error,
|
||||
);
|
||||
} else {
|
||||
eventTracker.track('Device state report failure', {
|
||||
error: retryInfo.error?.message ?? retryInfo.error,
|
||||
});
|
||||
|
||||
// Increase the counter so the healthcheck gets triggered
|
||||
// if too many connectivity errors occur
|
||||
stateReportErrors++;
|
||||
}
|
||||
log.info(
|
||||
`Retrying current state report in ${retryInfo.delay / 1000} seconds`,
|
||||
);
|
||||
}
|
||||
|
||||
async function generateStateForReport() {
|
||||
const { hardwareMetrics } = await config.getMany(['hardwareMetrics']);
|
||||
|
||||
const currentDeviceState = await deviceState.getStatus();
|
||||
|
||||
// If hardwareMetrics is false, send null patch for system metrics to cloud API
|
||||
const info = {
|
||||
...(hardwareMetrics
|
||||
? await sysInfo.getSystemMetrics()
|
||||
: {
|
||||
cpu_usage: null,
|
||||
memory_usage: null,
|
||||
memory_total: null,
|
||||
storage_usage: null,
|
||||
storage_total: null,
|
||||
storage_block_device: null,
|
||||
cpu_temp: null,
|
||||
cpu_id: null,
|
||||
}),
|
||||
...(await sysInfo.getSystemChecks()),
|
||||
};
|
||||
|
||||
return {
|
||||
local: {
|
||||
...currentDeviceState.local,
|
||||
...info,
|
||||
},
|
||||
dependent: currentDeviceState.dependent,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startReporting() {
|
||||
// Get configs needed to make a report
|
||||
const reportConfigs = (await config.getMany([
|
||||
'uuid',
|
||||
'apiEndpoint',
|
||||
'apiTimeout',
|
||||
'deviceApiKey',
|
||||
'deviceId',
|
||||
'localMode',
|
||||
'appUpdatePollInterval',
|
||||
'hardwareMetrics',
|
||||
])) as CurrentStateReportConf;
|
||||
// Throttle reportCurrentState so we don't query device or hit API excessively
|
||||
const throttledReport = _.throttle(
|
||||
reportCurrentState,
|
||||
constants.maxReportFrequency,
|
||||
);
|
||||
const doReport = async () => {
|
||||
if (!reportPending) {
|
||||
throttledReport(reportConfigs);
|
||||
}
|
||||
};
|
||||
|
||||
// If the state changes, report it
|
||||
deviceState.on('change', doReport);
|
||||
// But check once every max report frequency to ensure that changes in system
|
||||
// info are picked up (CPU temp etc)
|
||||
setInterval(doReport, constants.maxReportFrequency);
|
||||
// Try to perform a report right away
|
||||
return doReport();
|
||||
}
|
@ -1,7 +1,8 @@
|
||||
import * as _ from 'lodash';
|
||||
|
||||
import * as db from '../db';
|
||||
import * as targetStateCache from '../device-state/target-state-cache';
|
||||
import * as targetStateCache from './target-state-cache';
|
||||
import { DatabaseApp, DatabaseService } from './target-state-cache';
|
||||
|
||||
import App from '../compose/app';
|
||||
import * as images from '../compose/images';
|
||||
@ -10,9 +11,9 @@ import {
|
||||
InstancedAppState,
|
||||
TargetApp,
|
||||
TargetApps,
|
||||
TargetRelease,
|
||||
TargetService,
|
||||
} from '../types/state';
|
||||
import { checkInt } from '../lib/validation';
|
||||
|
||||
type InstancedApp = InstancedAppState[0];
|
||||
|
||||
@ -37,72 +38,114 @@ export async function getApps(): Promise<InstancedAppState> {
|
||||
}
|
||||
|
||||
export async function setApps(
|
||||
apps: { [appId: number]: TargetApp },
|
||||
apps: TargetApps,
|
||||
source: string,
|
||||
trx?: db.Transaction,
|
||||
) {
|
||||
const dbApps = await Promise.all(
|
||||
Object.keys(apps).map(async (appIdStr) => {
|
||||
const appId = checkInt(appIdStr)!;
|
||||
const dbApps = Object.keys(apps).map((uuid) => {
|
||||
const { id: appId, ...app } = apps[uuid];
|
||||
|
||||
const app = apps[appId];
|
||||
const services = await Promise.all(
|
||||
_.map(app.services, async (s, sId) => ({
|
||||
...s,
|
||||
appId,
|
||||
releaseId: app.releaseId,
|
||||
serviceId: checkInt(sId),
|
||||
commit: app.commit,
|
||||
image: await images.normalise(s.image),
|
||||
})),
|
||||
);
|
||||
// Get the first uuid
|
||||
const [releaseUuid] = Object.keys(app.releases);
|
||||
const release = releaseUuid
|
||||
? app.releases[releaseUuid]
|
||||
: ({} as TargetRelease);
|
||||
|
||||
const services = Object.keys(release.services ?? {}).map((serviceName) => {
|
||||
const { id: releaseId } = release;
|
||||
const { id: serviceId, image_id: imageId, ...service } = release.services[
|
||||
serviceName
|
||||
];
|
||||
|
||||
return {
|
||||
...service,
|
||||
appId,
|
||||
source,
|
||||
commit: app.commit,
|
||||
name: app.name,
|
||||
releaseId: app.releaseId,
|
||||
services: JSON.stringify(services),
|
||||
networks: JSON.stringify(app.networks ?? {}),
|
||||
volumes: JSON.stringify(app.volumes ?? {}),
|
||||
appUuid: uuid,
|
||||
releaseId,
|
||||
commit: releaseUuid,
|
||||
imageId,
|
||||
serviceId,
|
||||
serviceName,
|
||||
image: images.normalise(service.image),
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
appId,
|
||||
uuid,
|
||||
source,
|
||||
isHost: !!app.is_host,
|
||||
class: app.class,
|
||||
name: app.name,
|
||||
...(releaseUuid && { releaseId: release.id, commit: releaseUuid }),
|
||||
services: JSON.stringify(services),
|
||||
networks: JSON.stringify(release.networks ?? {}),
|
||||
volumes: JSON.stringify(release.volumes ?? {}),
|
||||
};
|
||||
});
|
||||
|
||||
await targetStateCache.setTargetApps(dbApps, trx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create target state from database state
|
||||
*/
|
||||
export async function getTargetJson(): Promise<TargetApps> {
|
||||
const dbApps = await getDBEntry();
|
||||
const apps: TargetApps = {};
|
||||
await Promise.all(
|
||||
dbApps.map(async (app) => {
|
||||
const parsedServices = JSON.parse(app.services);
|
||||
|
||||
const services = _(parsedServices)
|
||||
.keyBy('serviceId')
|
||||
.mapValues(
|
||||
(svc: TargetService) => _.omit(svc, 'commit') as TargetService,
|
||||
)
|
||||
.value();
|
||||
return dbApps
|
||||
.map(({ source, uuid, releaseId, commit: releaseUuid, ...app }): [
|
||||
string,
|
||||
TargetApp,
|
||||
] => {
|
||||
const services = (JSON.parse(app.services) as DatabaseService[])
|
||||
.map(({ serviceName, serviceId, imageId, ...service }): [
|
||||
string,
|
||||
TargetService,
|
||||
] => [
|
||||
serviceName,
|
||||
{
|
||||
id: serviceId,
|
||||
image_id: imageId,
|
||||
..._.omit(service, ['appId', 'appUuid', 'commit', 'releaseId']),
|
||||
} as TargetService,
|
||||
])
|
||||
// Map by serviceName
|
||||
.reduce(
|
||||
(svcs, [serviceName, s]) => ({
|
||||
...svcs,
|
||||
[serviceName]: s,
|
||||
}),
|
||||
{},
|
||||
);
|
||||
|
||||
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 TargetApp;
|
||||
}),
|
||||
);
|
||||
return apps;
|
||||
const releases = releaseUuid
|
||||
? {
|
||||
[releaseUuid]: {
|
||||
id: releaseId,
|
||||
services,
|
||||
networks: JSON.parse(app.networks),
|
||||
volumes: JSON.parse(app.volumes),
|
||||
} as TargetRelease,
|
||||
}
|
||||
: {};
|
||||
|
||||
return [
|
||||
uuid,
|
||||
{
|
||||
id: app.appId,
|
||||
name: app.name,
|
||||
class: app.class,
|
||||
is_host: !!app.isHost,
|
||||
releases,
|
||||
},
|
||||
];
|
||||
})
|
||||
.reduce((apps, [uuid, app]) => ({ ...apps, [uuid]: app }), {});
|
||||
}
|
||||
|
||||
function getDBEntry(): Promise<targetStateCache.DatabaseApp[]>;
|
||||
function getDBEntry(appId: number): Promise<targetStateCache.DatabaseApp>;
|
||||
function getDBEntry(): Promise<DatabaseApp[]>;
|
||||
function getDBEntry(appId: number): Promise<DatabaseApp>;
|
||||
async function getDBEntry(appId?: number) {
|
||||
await targetStateCache.initialized;
|
||||
|
||||
|
116
src/device-state/legacy.ts
Normal file
116
src/device-state/legacy.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import * as _ from 'lodash';
|
||||
import { fromV2TargetApps, TargetAppsV2 } from '../lib/legacy';
|
||||
import { AppsJsonFormat, TargetApp, TargetRelease } from '../types';
|
||||
|
||||
/**
|
||||
* Converts a single app from single container format into
|
||||
* multi-container, multi-app format (v3)
|
||||
*
|
||||
* This function doesn't pull ids from the cloud, but uses dummy values,
|
||||
* letting the normaliseLegacyDatabase() method perform the normalization
|
||||
*/
|
||||
function singleToMulticontainerApp(
|
||||
app: Dictionary<any>,
|
||||
): TargetApp & { uuid: string } {
|
||||
const environment: Dictionary<string> = {};
|
||||
for (const key in app.env) {
|
||||
if (!/^RESIN_/.test(key)) {
|
||||
environment[key] = app.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
const { appId } = app;
|
||||
|
||||
const release: TargetRelease = {
|
||||
id: 1,
|
||||
networks: {},
|
||||
volumes: {},
|
||||
services: {},
|
||||
};
|
||||
const conf = app.config != null ? app.config : {};
|
||||
const newApp: TargetApp & { uuid: string } = {
|
||||
id: appId,
|
||||
uuid: 'user-app',
|
||||
name: app.name,
|
||||
class: 'fleet',
|
||||
releases: {
|
||||
[app.commit]: release,
|
||||
},
|
||||
};
|
||||
const defaultVolume = exports.defaultLegacyVolume();
|
||||
release.volumes[defaultVolume] = {};
|
||||
const updateStrategy =
|
||||
conf['RESIN_SUPERVISOR_UPDATE_STRATEGY'] != null
|
||||
? conf['RESIN_SUPERVISOR_UPDATE_STRATEGY']
|
||||
: 'download-then-kill';
|
||||
const handoverTimeout =
|
||||
conf['RESIN_SUPERVISOR_HANDOVER_TIMEOUT'] != null
|
||||
? conf['RESIN_SUPERVISOR_HANDOVER_TIMEOUT']
|
||||
: '';
|
||||
const restartPolicy =
|
||||
conf['RESIN_APP_RESTART_POLICY'] != null
|
||||
? conf['RESIN_APP_RESTART_POLICY']
|
||||
: 'always';
|
||||
release.services = {
|
||||
main: {
|
||||
id: 1,
|
||||
image_id: 1,
|
||||
image: app.imageId,
|
||||
labels: {
|
||||
'io.resin.features.kernel-modules': '1',
|
||||
'io.resin.features.firmware': '1',
|
||||
'io.resin.features.dbus': '1',
|
||||
'io.resin.features.supervisor-api': '1',
|
||||
'io.resin.features.resin-api': '1',
|
||||
'io.resin.update.strategy': updateStrategy,
|
||||
'io.resin.update.handover-timeout': handoverTimeout,
|
||||
'io.resin.legacy-container': '1',
|
||||
},
|
||||
environment,
|
||||
running: true,
|
||||
composition: {
|
||||
restart: restartPolicy,
|
||||
privileged: true,
|
||||
networkMode: 'host',
|
||||
volumes: [`${defaultVolume}:/data`],
|
||||
},
|
||||
},
|
||||
};
|
||||
return newApp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an apps.json from single container to multi-app (v3) format.
|
||||
*/
|
||||
export function fromLegacyAppsJson(appsArray: any[]): AppsJsonFormat {
|
||||
const deviceConfig = _.reduce(
|
||||
appsArray,
|
||||
(conf, app) => {
|
||||
return _.merge({}, conf, app.config);
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const apps = _.keyBy(
|
||||
_.map(appsArray, singleToMulticontainerApp),
|
||||
'uuid',
|
||||
) as Dictionary<TargetApp>;
|
||||
return { apps, config: deviceConfig } as AppsJsonFormat;
|
||||
}
|
||||
|
||||
type AppsJsonV2 = {
|
||||
config: {
|
||||
[varName: string]: string;
|
||||
};
|
||||
apps: TargetAppsV2;
|
||||
pinDevice?: boolean;
|
||||
};
|
||||
|
||||
export async function fromV2AppsJson(
|
||||
appsJson: AppsJsonV2,
|
||||
): Promise<AppsJsonFormat> {
|
||||
const { config: conf, apps, pinDevice } = appsJson;
|
||||
|
||||
const v3apps = await fromV2TargetApps(apps);
|
||||
return { config: conf, apps: v3apps, ...(pinDevice && { pinDevice }) };
|
||||
}
|
@ -6,14 +6,21 @@ import * as deviceState from '../device-state';
|
||||
import * as config from '../config';
|
||||
import * as deviceConfig from '../device-config';
|
||||
import * as eventTracker from '../event-tracker';
|
||||
import * as images from '../compose/images';
|
||||
import * as imageManager from '../compose/images';
|
||||
|
||||
import { AppsJsonParseError, EISDIR, ENOENT } from '../lib/errors';
|
||||
import {
|
||||
AppsJsonParseError,
|
||||
EISDIR,
|
||||
ENOENT,
|
||||
InternalInconsistencyError,
|
||||
} from '../lib/errors';
|
||||
import log from '../lib/supervisor-console';
|
||||
|
||||
import { convertLegacyAppsJson } from '../lib/migration';
|
||||
import { fromLegacyAppsJson, fromV2AppsJson } from './legacy';
|
||||
import { AppsJsonFormat } from '../types/state';
|
||||
import * as fsUtils from '../lib/fs-utils';
|
||||
import { isLeft } from 'fp-ts/lib/Either';
|
||||
import Reporter from 'io-ts-reporters';
|
||||
|
||||
export function appsJsonBackup(appsPath: string) {
|
||||
return `${appsPath}.preloaded`;
|
||||
@ -35,53 +42,80 @@ export async function loadTargetFromFile(appsPath: string): Promise<boolean> {
|
||||
|
||||
if (_.isArray(stateFromFile)) {
|
||||
log.debug('Detected a legacy apps.json, converting...');
|
||||
stateFromFile = convertLegacyAppsJson(stateFromFile as any[]);
|
||||
stateFromFile = fromLegacyAppsJson(stateFromFile as any[]);
|
||||
}
|
||||
const preloadState = stateFromFile as AppsJsonFormat;
|
||||
|
||||
let commitToPin: string | undefined;
|
||||
let appToPin: string | undefined;
|
||||
// if apps.json apps are keyed by numeric ids, then convert to v3 target state
|
||||
if (
|
||||
Object.keys(stateFromFile.apps || {}).some(
|
||||
(appId) => !isNaN(parseInt(appId, 10)),
|
||||
)
|
||||
) {
|
||||
stateFromFile = await fromV2AppsJson(stateFromFile as any);
|
||||
}
|
||||
|
||||
if (_.isEmpty(preloadState)) {
|
||||
// Check that transformed apps.json has the correct format
|
||||
const decodedAppsJson = AppsJsonFormat.decode(stateFromFile);
|
||||
if (isLeft(decodedAppsJson)) {
|
||||
throw new AppsJsonParseError(
|
||||
['Invalid apps.json.']
|
||||
.concat(Reporter.report(decodedAppsJson))
|
||||
.join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
// If decoding apps.json succeeded then preloadState will have the right format
|
||||
const preloadState = decodedAppsJson.right;
|
||||
|
||||
if (_.isEmpty(preloadState.config) && _.isEmpty(preloadState.apps)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const imgs: Image[] = [];
|
||||
const appIds = _.keys(preloadState.apps);
|
||||
for (const appId of appIds) {
|
||||
const app = preloadState.apps[appId];
|
||||
// Multi-app warning!
|
||||
// The following will need to be changed once running
|
||||
// multiple applications is possible
|
||||
commitToPin = app.commit;
|
||||
appToPin = appId;
|
||||
const serviceIds = _.keys(app.services);
|
||||
for (const serviceId of serviceIds) {
|
||||
const service = app.services[serviceId];
|
||||
const svc = {
|
||||
imageName: service.image,
|
||||
serviceName: service.serviceName,
|
||||
imageId: service.imageId,
|
||||
serviceId: parseInt(serviceId, 10),
|
||||
releaseId: app.releaseId,
|
||||
appId: parseInt(appId, 10),
|
||||
};
|
||||
imgs.push(imageFromService(svc));
|
||||
}
|
||||
const uuid = await config.get('uuid');
|
||||
|
||||
if (!uuid) {
|
||||
throw new InternalInconsistencyError(
|
||||
`No uuid found for the local device`,
|
||||
);
|
||||
}
|
||||
|
||||
const imgs: Image[] = Object.keys(preloadState.apps)
|
||||
.map((appUuid) => {
|
||||
const app = preloadState.apps[appUuid];
|
||||
const [releaseUuid] = Object.keys(app.releases);
|
||||
const release = app.releases[releaseUuid] ?? {};
|
||||
const services = release?.services ?? {};
|
||||
return Object.keys(services).map((serviceName) => {
|
||||
const service = services[serviceName];
|
||||
const svc = {
|
||||
imageName: service.image,
|
||||
serviceName,
|
||||
imageId: service.image_id,
|
||||
serviceId: service.id,
|
||||
releaseId: release.id,
|
||||
commit: releaseUuid,
|
||||
appId: app.id,
|
||||
appUuid,
|
||||
};
|
||||
return imageFromService(svc);
|
||||
});
|
||||
})
|
||||
.reduce((res, images) => res.concat(images), []);
|
||||
|
||||
for (const image of imgs) {
|
||||
const name = images.normalise(image.name);
|
||||
const name = imageManager.normalise(image.name);
|
||||
image.name = name;
|
||||
await images.save(image);
|
||||
await imageManager.save(image);
|
||||
}
|
||||
|
||||
const deviceConf = await deviceConfig.getCurrent();
|
||||
const formattedConf = deviceConfig.formatConfigKeys(preloadState.config);
|
||||
preloadState.config = { ...formattedConf, ...deviceConf };
|
||||
const localState = {
|
||||
local: { name: '', ...preloadState },
|
||||
dependent: { apps: {}, devices: {} },
|
||||
[uuid]: {
|
||||
name: '',
|
||||
config: { ...formattedConf, ...deviceConf },
|
||||
apps: preloadState.apps,
|
||||
},
|
||||
};
|
||||
|
||||
await deviceState.setTarget(localState);
|
||||
@ -89,13 +123,17 @@ export async function loadTargetFromFile(appsPath: string): Promise<boolean> {
|
||||
if (preloadState.pinDevice) {
|
||||
// Multi-app warning!
|
||||
// The following will need to be changed once running
|
||||
// multiple applications is possible
|
||||
// multiple applications is possible.
|
||||
// For now, just select the first app with 'fleet' class (there can be only one)
|
||||
const [appToPin] = Object.values(preloadState.apps).filter(
|
||||
(app) => app.class === 'fleet',
|
||||
);
|
||||
const [commitToPin] = Object.keys(appToPin?.releases ?? {});
|
||||
if (commitToPin != null && appToPin != null) {
|
||||
log.debug('Device will be pinned');
|
||||
await config.set({
|
||||
pinDevice: {
|
||||
commit: commitToPin,
|
||||
app: parseInt(appToPin, 10),
|
||||
app: appToPin.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -109,6 +147,7 @@ export async function loadTargetFromFile(appsPath: string): Promise<boolean> {
|
||||
if (ENOENT(e) || EISDIR(e)) {
|
||||
log.debug('No apps.json file present, skipping preload');
|
||||
} else {
|
||||
log.debug(e.message);
|
||||
eventTracker.track('Loading preloaded apps failed', {
|
||||
error: e,
|
||||
});
|
||||
|
@ -2,20 +2,44 @@ import * as _ from 'lodash';
|
||||
|
||||
import * as config from '../config';
|
||||
import * as db from '../db';
|
||||
import { TargetAppClass } from '../types';
|
||||
|
||||
// We omit the id (which does appear in the db) in this type, as we don't use it
|
||||
// at all, and we can use the below type for both insertion and retrieval.
|
||||
export interface DatabaseApp {
|
||||
name: string;
|
||||
/**
|
||||
* @deprecated to be removed in target state v4
|
||||
*/
|
||||
releaseId?: number;
|
||||
commit?: string;
|
||||
/**
|
||||
* @deprecated to be removed in target state v4
|
||||
*/
|
||||
appId: number;
|
||||
uuid: string;
|
||||
services: string;
|
||||
networks: string;
|
||||
volumes: string;
|
||||
source: string;
|
||||
class: TargetAppClass;
|
||||
isHost: boolean;
|
||||
}
|
||||
export type DatabaseApps = DatabaseApp[];
|
||||
|
||||
export type DatabaseService = {
|
||||
appId: string;
|
||||
appUuid: string;
|
||||
releaseId: number;
|
||||
commit: string;
|
||||
serviceName: string;
|
||||
serviceId: number;
|
||||
imageId: number;
|
||||
image: string;
|
||||
|
||||
labels: { [key: string]: string };
|
||||
environment: { [key: string]: string };
|
||||
composition?: { [key: string]: any };
|
||||
};
|
||||
|
||||
/*
|
||||
* This module is a wrapper around the database fetching and retrieving of
|
||||
@ -26,7 +50,7 @@ export type DatabaseApps = DatabaseApp[];
|
||||
* accesses the target state for every log line. This can very quickly cause
|
||||
* serious memory problems and database connection timeouts.
|
||||
*/
|
||||
let targetState: DatabaseApps | undefined;
|
||||
let targetState: DatabaseApp[] | undefined;
|
||||
|
||||
export const initialized = (async () => {
|
||||
await db.initialized;
|
||||
@ -53,7 +77,7 @@ export async function getTargetApp(
|
||||
return _.find(targetState, (app) => app.appId === appId);
|
||||
}
|
||||
|
||||
export async function getTargetApps(): Promise<DatabaseApps> {
|
||||
export async function getTargetApps(): Promise<DatabaseApp[]> {
|
||||
if (targetState == null) {
|
||||
const { apiEndpoint, localMode } = await config.getMany([
|
||||
'apiEndpoint',
|
||||
@ -61,13 +85,19 @@ export async function getTargetApps(): Promise<DatabaseApps> {
|
||||
]);
|
||||
|
||||
const source = localMode ? 'local' : apiEndpoint;
|
||||
targetState = await db.models('app').where({ source });
|
||||
targetState = await db
|
||||
.models('app')
|
||||
.where({ source })
|
||||
// Local mode only applies for fleet "applications"
|
||||
// this prevents the supervisor trying to uninstall
|
||||
// the supervisor or host app for tri-app
|
||||
.orWhereNot({ class: 'fleet' });
|
||||
}
|
||||
return targetState!;
|
||||
}
|
||||
|
||||
export async function setTargetApps(
|
||||
apps: DatabaseApps,
|
||||
apps: DatabaseApp[],
|
||||
trx?: db.Transaction,
|
||||
): Promise<void> {
|
||||
// We can't cache the value here, as it could be for a
|
||||
@ -75,6 +105,6 @@ export async function setTargetApps(
|
||||
targetState = undefined;
|
||||
|
||||
await Promise.all(
|
||||
apps.map((app) => db.upsertModel('app', app, { appId: app.appId }, trx)),
|
||||
apps.map((app) => db.upsertModel('app', app, { uuid: app.uuid }, trx)),
|
||||
);
|
||||
}
|
||||
|
@ -135,7 +135,7 @@ export const update = async (
|
||||
);
|
||||
}
|
||||
|
||||
const endpoint = url.resolve(apiEndpoint, `/device/v2/${uuid}/state`);
|
||||
const endpoint = url.resolve(apiEndpoint, `/device/v3/${uuid}/state`);
|
||||
const request = await getRequestInstance();
|
||||
|
||||
const params: CoreOptions = {
|
||||
|
@ -1,27 +1,16 @@
|
||||
import { isLeft } from 'fp-ts/lib/Either';
|
||||
import * as t from 'io-ts';
|
||||
import { reporter } from 'io-ts-reporters';
|
||||
import Reporter from 'io-ts-reporters';
|
||||
import * as _ from 'lodash';
|
||||
|
||||
import { Blueprint, Contract, ContractObject } from '@balena/contrato';
|
||||
|
||||
import { ContractValidationError, InternalInconsistencyError } from './errors';
|
||||
import { checkTruthy } from './validation';
|
||||
import { TargetApps } from '../types';
|
||||
|
||||
export { ContractObject };
|
||||
|
||||
// TODO{type}: When target and current state are correctly
|
||||
// defined, replace this
|
||||
interface AppWithContracts {
|
||||
services: {
|
||||
[key: string]: {
|
||||
serviceName: string;
|
||||
contract?: ContractObject;
|
||||
labels?: Dictionary<string>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApplicationContractResult {
|
||||
valid: boolean;
|
||||
unmetServices: string[];
|
||||
@ -194,7 +183,7 @@ export function validateContract(contract: unknown): boolean {
|
||||
const result = contractObjectValidator.decode(contract);
|
||||
|
||||
if (isLeft(result)) {
|
||||
throw new Error(reporter(result).join('\n'));
|
||||
throw new Error(Reporter.report(result).join('\n'));
|
||||
}
|
||||
|
||||
const requirementVersions = contractRequirementVersions;
|
||||
@ -208,46 +197,66 @@ export function validateContract(contract: unknown): boolean {
|
||||
return true;
|
||||
}
|
||||
export function validateTargetContracts(
|
||||
apps: Dictionary<AppWithContracts>,
|
||||
apps: TargetApps,
|
||||
): Dictionary<ApplicationContractResult> {
|
||||
const appsFulfilled: Dictionary<ApplicationContractResult> = {};
|
||||
return Object.keys(apps)
|
||||
.map((appUuid): [string, ApplicationContractResult] => {
|
||||
const app = apps[appUuid];
|
||||
const [release] = Object.values(app.releases);
|
||||
const serviceContracts = Object.keys(release?.services ?? [])
|
||||
.map((serviceName) => {
|
||||
const service = release.services[serviceName];
|
||||
const { contract } = service;
|
||||
if (contract) {
|
||||
try {
|
||||
// Validate the contract syntax
|
||||
validateContract(contract);
|
||||
|
||||
for (const appId of _.keys(apps)) {
|
||||
const app = apps[appId];
|
||||
const serviceContracts: ServiceContracts = {};
|
||||
return {
|
||||
serviceName,
|
||||
contract,
|
||||
optional: checkTruthy(
|
||||
service.labels?.['io.balena.features.optional'],
|
||||
),
|
||||
};
|
||||
} catch (e) {
|
||||
throw new ContractValidationError(serviceName, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
for (const svcId of _.keys(app.services)) {
|
||||
const svc = app.services[svcId];
|
||||
// Return a default contract for the service if no contract is defined
|
||||
return { serviceName, contract: undefined, optional: false };
|
||||
})
|
||||
// map by serviceName
|
||||
.reduce(
|
||||
(contracts, { serviceName, ...serviceContract }) => ({
|
||||
...contracts,
|
||||
[serviceName]: serviceContract,
|
||||
}),
|
||||
{} as ServiceContracts,
|
||||
);
|
||||
|
||||
if (svc.contract) {
|
||||
try {
|
||||
validateContract(svc.contract);
|
||||
|
||||
serviceContracts[svc.serviceName] = {
|
||||
contract: svc.contract,
|
||||
optional: checkTruthy(svc.labels?.['io.balena.features.optional']),
|
||||
};
|
||||
} catch (e) {
|
||||
throw new ContractValidationError(svc.serviceName, e.message);
|
||||
}
|
||||
} else {
|
||||
serviceContracts[svc.serviceName] = {
|
||||
contract: undefined,
|
||||
optional: false,
|
||||
};
|
||||
if (Object.keys(serviceContracts).length > 0) {
|
||||
// Validate service contracts if any
|
||||
return [appUuid, containerContractsFulfilled(serviceContracts)];
|
||||
}
|
||||
|
||||
if (!_.isEmpty(serviceContracts)) {
|
||||
appsFulfilled[appId] = containerContractsFulfilled(serviceContracts);
|
||||
} else {
|
||||
appsFulfilled[appId] = {
|
||||
// Return success if no services are found
|
||||
return [
|
||||
appUuid,
|
||||
{
|
||||
valid: true,
|
||||
fulfilledServices: _.map(app.services, 'serviceName'),
|
||||
fulfilledServices: Object.keys(release?.services ?? []),
|
||||
unmetAndOptional: [],
|
||||
unmetServices: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return appsFulfilled;
|
||||
},
|
||||
];
|
||||
})
|
||||
.reduce(
|
||||
(result, [appUuid, contractFulfilled]) => ({
|
||||
...result,
|
||||
[appUuid]: contractFulfilled,
|
||||
}),
|
||||
{} as Dictionary<ApplicationContractResult>,
|
||||
);
|
||||
}
|
||||
|
@ -49,12 +49,6 @@ export class InvalidNetGatewayError extends TypedError {}
|
||||
|
||||
export class DeltaStillProcessingError extends TypedError {}
|
||||
|
||||
export class InvalidAppIdError extends TypedError {
|
||||
public constructor(public appId: any) {
|
||||
super(`Invalid appId: ${appId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdatesLockedError extends TypedError {}
|
||||
|
||||
export function isHttpConflictError(err: { statusCode: number }): boolean {
|
||||
|
134
src/lib/json.ts
Normal file
134
src/lib/json.ts
Normal file
@ -0,0 +1,134 @@
|
||||
function isObject(value: unknown): value is object {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates deep equality between javascript
|
||||
* objects
|
||||
*/
|
||||
export function equals<T>(value: T, other: T): boolean {
|
||||
if (isObject(value) && isObject(other)) {
|
||||
const [vProps, oProps] = [value, other].map(
|
||||
(a) => Object.getOwnPropertyNames(a) as Array<keyof T>,
|
||||
);
|
||||
if (vProps.length !== oProps.length) {
|
||||
// If the property lists are different lengths we don't need
|
||||
// to check any further
|
||||
return false;
|
||||
}
|
||||
|
||||
// Otherwise this comparison will catch it. This works even
|
||||
// for arrays as getOwnPropertyNames returns the list of indexes
|
||||
// for each array
|
||||
return vProps.every((key) => equals(value[key], other[key]));
|
||||
}
|
||||
|
||||
return value === other;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the the object equals `{}` or is an empty
|
||||
* array
|
||||
*/
|
||||
export function empty<T>(value: T): boolean {
|
||||
return (Array.isArray(value) && value.length === 0) || equals(value, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the difference between the dst object and src object
|
||||
* and return both the object and whether there are any changes
|
||||
*/
|
||||
function diffcmp<T>(src: T, tgt: T, depth = Infinity): [Partial<T>, boolean] {
|
||||
if (!isObject(src) || !isObject(tgt)) {
|
||||
// Always returns tgt in this case, but let the caller
|
||||
// know if there have been any changes
|
||||
return [tgt, src !== tgt];
|
||||
}
|
||||
|
||||
// Compare arrays when reporting differences
|
||||
if (Array.isArray(src) || Array.isArray(tgt) || depth === 0) {
|
||||
return [tgt, !equals(src, tgt)];
|
||||
}
|
||||
|
||||
const r = (Object.getOwnPropertyNames(tgt) as Array<keyof T>)
|
||||
.map((key) => {
|
||||
const [delta, changed] = diffcmp(src[key], tgt[key], depth - 1);
|
||||
return changed ? { [key]: delta } : {};
|
||||
})
|
||||
.concat(
|
||||
(Object.getOwnPropertyNames(src) as Array<keyof T>).map((key) => {
|
||||
const [delta, changed] = diffcmp(src[key], tgt[key], depth - 1);
|
||||
return changed ? { [key]: delta } : {};
|
||||
}),
|
||||
)
|
||||
.reduce((res, delta) => ({ ...res, ...delta }), {} as Partial<T>);
|
||||
|
||||
return [r, Object.keys(r).length > 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the difference between the target object and the source object.
|
||||
*
|
||||
* This considers both additive and substractive differences. If both the source
|
||||
* and target elements are arrays, it returns the value of the target array
|
||||
* (no array comparison)
|
||||
* e.g.
|
||||
* ```
|
||||
* // Returns `{b:2}`
|
||||
* diff({a:1}, {a: 1, b:2})
|
||||
*
|
||||
* // Returns `{b: undefined}`
|
||||
* diff({a:1, b:2}, {a: 1})
|
||||
* ```
|
||||
*/
|
||||
export function diff<T>(src: T, tgt: T): Partial<T> {
|
||||
const [res] = diffcmp(src, tgt);
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calulate the difference between the target object and the source
|
||||
* object up to the given depth.
|
||||
*
|
||||
* If depth is 0, it compares using `equals` and return the target if they are
|
||||
* different
|
||||
*
|
||||
* shallowDiff(src,tgt, Infinity) return the same result as diff(src, tgt)
|
||||
*/
|
||||
export function shallowDiff<T>(src: T, tgt: T, depth = 1): Partial<T> {
|
||||
const [res] = diffcmp(src, tgt, depth);
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes empty branches from the json object
|
||||
*
|
||||
* e.g.
|
||||
* ```
|
||||
* prune({a: 1, b: {}})
|
||||
* // Returns `{a: 1}`
|
||||
* prune({a: 1, b: {}})
|
||||
*
|
||||
* // Returns `{a: 1, b: {c:1}}`
|
||||
* prune({a: 1, b: {c: 1, d: {}}})
|
||||
* ```
|
||||
*/
|
||||
export function prune<T>(obj: T): Partial<T> {
|
||||
if (!isObject(obj) || Array.isArray(obj)) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
return (Object.getOwnPropertyNames(obj) as Array<keyof T>)
|
||||
.map((key) => {
|
||||
const prunedChild = prune(obj[key]);
|
||||
if (
|
||||
isObject(obj[key]) &&
|
||||
!Array.isArray(obj) &&
|
||||
equals(prunedChild, {})
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
return { [key]: prunedChild };
|
||||
})
|
||||
.reduce((res, delta) => ({ ...res, ...delta }), {} as Partial<T>);
|
||||
}
|
376
src/lib/legacy.ts
Normal file
376
src/lib/legacy.ts
Normal file
@ -0,0 +1,376 @@
|
||||
import * as _ from 'lodash';
|
||||
|
||||
import * as path from 'path';
|
||||
import * as apiBinder from '../api-binder';
|
||||
import * as config from '../config';
|
||||
import * as db from '../db';
|
||||
import * as volumeManager from '../compose/volume-manager';
|
||||
import * as serviceManager from '../compose/service-manager';
|
||||
import * as deviceState from '../device-state';
|
||||
import * as applicationManager from '../compose/application-manager';
|
||||
import {
|
||||
StatusError,
|
||||
DatabaseParseError,
|
||||
NotFoundError,
|
||||
InternalInconsistencyError,
|
||||
} from '../lib/errors';
|
||||
import * as constants from '../lib/constants';
|
||||
import { docker } from '../lib/docker-utils';
|
||||
import { log } from '../lib/supervisor-console';
|
||||
import Volume from '../compose/volume';
|
||||
import * as logger from '../logger';
|
||||
import type {
|
||||
DatabaseApp,
|
||||
DatabaseService,
|
||||
} from '../device-state/target-state-cache';
|
||||
|
||||
import { TargetApp, TargetApps, TargetState } from '../types';
|
||||
|
||||
const defaultLegacyVolume = () => 'resin-data';
|
||||
|
||||
/**
|
||||
* Creates a docker volume from the legacy data directory
|
||||
*/
|
||||
async function createVolumeFromLegacyData(
|
||||
appId: number,
|
||||
appUuid: string,
|
||||
): Promise<Volume | void> {
|
||||
const name = defaultLegacyVolume();
|
||||
const legacyPath = path.join(
|
||||
constants.rootMountPoint,
|
||||
'mnt/data/resin-data',
|
||||
appId.toString(),
|
||||
);
|
||||
|
||||
try {
|
||||
return await volumeManager.createFromPath(
|
||||
{ name, appId, appUuid },
|
||||
{},
|
||||
legacyPath,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.logSystemMessage(
|
||||
`Warning: could not migrate legacy /data volume: ${e.message}`,
|
||||
{ error: e },
|
||||
'Volume migration error',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets proper database ids from the cloud for the app and app services
|
||||
*/
|
||||
export async function normaliseLegacyDatabase() {
|
||||
await apiBinder.initialized;
|
||||
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
|
||||
log.info('Migrating ids for legacy app...');
|
||||
|
||||
const apps: DatabaseApp[] = await db.models('app').select();
|
||||
|
||||
if (apps.length === 0) {
|
||||
log.debug('No app to migrate');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const app of apps) {
|
||||
let services: DatabaseService[];
|
||||
|
||||
try {
|
||||
services = JSON.parse(app.services);
|
||||
} catch (e) {
|
||||
throw new DatabaseParseError(e);
|
||||
}
|
||||
|
||||
// Check there's a main service, with legacy-container set
|
||||
if (services.length !== 1) {
|
||||
log.debug("App doesn't have a single service, ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
const service = services[0];
|
||||
if (
|
||||
!service.labels['io.resin.legacy-container'] &&
|
||||
!service.labels['io.balena.legacy-container']
|
||||
) {
|
||||
log.debug('Service is not marked as legacy, ignoring');
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug(`Getting release ${app.commit} for app ${app.appId} from API`);
|
||||
const releases = await apiBinder.balenaApi.get({
|
||||
resource: 'release',
|
||||
options: {
|
||||
$filter: {
|
||||
belongs_to__application: app.appId,
|
||||
commit: app.commit,
|
||||
status: 'success',
|
||||
},
|
||||
$expand: {
|
||||
contains__image: {
|
||||
$expand: 'image',
|
||||
},
|
||||
belongs_to__application: {
|
||||
$select: ['uuid'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (releases.length === 0) {
|
||||
log.warn(
|
||||
`No compatible releases found in API, removing ${app.appId} from target state`,
|
||||
);
|
||||
await db.models('app').where({ appId: app.appId }).del();
|
||||
}
|
||||
|
||||
// We need to get the app.uuid, release.id, serviceId, image.id and updated imageUrl
|
||||
const release = releases[0];
|
||||
const appUuid = release.belongs_to__application[0].uuid;
|
||||
const image = release.contains__image[0].image[0];
|
||||
const serviceId = image.is_a_build_of__service.__id;
|
||||
const imageUrl = !image.content_hash
|
||||
? image.is_stored_at__image_location
|
||||
: `${image.is_stored_at__image_location}@${image.content_hash}`;
|
||||
|
||||
log.debug(
|
||||
`Found a release with releaseId ${release.id}, imageId ${image.id}, serviceId ${serviceId}\nImage location is ${imageUrl}`,
|
||||
);
|
||||
|
||||
const imageFromDocker = await docker
|
||||
.getImage(service.image)
|
||||
.inspect()
|
||||
.catch((error) => {
|
||||
if (error instanceof NotFoundError) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
const imagesFromDatabase = await db
|
||||
.models('image')
|
||||
.where({ name: service.image })
|
||||
.select();
|
||||
|
||||
await db.transaction(async (trx: db.Transaction) => {
|
||||
try {
|
||||
if (imagesFromDatabase.length > 0) {
|
||||
log.debug('Deleting existing image entry in db');
|
||||
await trx('image').where({ name: service.image }).del();
|
||||
} else {
|
||||
log.debug('No image in db to delete');
|
||||
}
|
||||
} finally {
|
||||
if (imageFromDocker != null) {
|
||||
log.debug('Inserting fixed image entry in db');
|
||||
await trx('image').insert({
|
||||
name: imageUrl,
|
||||
appId: app.appId,
|
||||
appUuid,
|
||||
serviceId,
|
||||
serviceName: service.serviceName,
|
||||
imageId: image.id,
|
||||
releaseId: release.id,
|
||||
commit: app.commit,
|
||||
dependent: 0,
|
||||
dockerImageId: imageFromDocker.Id,
|
||||
});
|
||||
} else {
|
||||
log.debug('Image is not downloaded, so not saving it to db');
|
||||
}
|
||||
|
||||
delete service.labels['io.resin.legacy-container'];
|
||||
delete service.labels['io.balena.legacy-container'];
|
||||
|
||||
Object.assign(app, {
|
||||
services: JSON.stringify([
|
||||
Object.assign(service, {
|
||||
appId: app.appId,
|
||||
appUuid,
|
||||
image: imageUrl,
|
||||
serviceId,
|
||||
imageId: image.id,
|
||||
releaseId: release.id,
|
||||
commit: app.commit,
|
||||
}),
|
||||
]),
|
||||
uuid: appUuid,
|
||||
releaseId: release.id,
|
||||
class: 'fleet',
|
||||
});
|
||||
|
||||
log.debug('Updating app entry in db');
|
||||
log.success('Successfully migrated legacy application');
|
||||
await trx('app').update(app).where({ appId: app.appId });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
log.debug('Killing legacy containers');
|
||||
await serviceManager.killAllLegacy();
|
||||
log.debug('Migrating legacy app volumes');
|
||||
|
||||
await applicationManager.initialized;
|
||||
const targetApps = await applicationManager.getTargetApps();
|
||||
|
||||
for (const appUuid of Object.keys(targetApps)) {
|
||||
const app = targetApps[appUuid];
|
||||
await createVolumeFromLegacyData(app.id, appUuid);
|
||||
}
|
||||
|
||||
await config.set({
|
||||
legacyAppsPresent: false,
|
||||
});
|
||||
}
|
||||
|
||||
export type TargetAppsV2 = {
|
||||
[id: string]: {
|
||||
name: string;
|
||||
commit?: string;
|
||||
releaseId?: number;
|
||||
services: { [id: string]: any };
|
||||
volumes: { [name: string]: any };
|
||||
networks: { [name: string]: any };
|
||||
};
|
||||
};
|
||||
|
||||
type TargetStateV2 = {
|
||||
local: {
|
||||
name: string;
|
||||
config: { [name: string]: string };
|
||||
apps: TargetAppsV2;
|
||||
};
|
||||
};
|
||||
|
||||
export async function fromV2TargetState(
|
||||
target: TargetStateV2,
|
||||
local = false,
|
||||
): Promise<TargetState> {
|
||||
const { uuid, name } = await config.getMany(['uuid', 'name']);
|
||||
if (!uuid) {
|
||||
throw new InternalInconsistencyError('No UUID for device');
|
||||
}
|
||||
|
||||
return {
|
||||
[uuid]: {
|
||||
name: target?.local?.name ?? name,
|
||||
config: target?.local?.config ?? {},
|
||||
apps: await fromV2TargetApps(target?.local?.apps ?? {}, local),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert v2 to v3 target apps. If local is false
|
||||
* it will query the API to get the app uuid
|
||||
*/
|
||||
export async function fromV2TargetApps(
|
||||
apps: TargetAppsV2,
|
||||
local = false,
|
||||
): Promise<TargetApps> {
|
||||
await apiBinder.initialized;
|
||||
await deviceState.initialized;
|
||||
|
||||
if (apiBinder.balenaApi == null) {
|
||||
throw new InternalInconsistencyError(
|
||||
'API binder is not initialized correctly',
|
||||
);
|
||||
}
|
||||
|
||||
const { balenaApi } = apiBinder;
|
||||
const getUUIDFromAPI = async (appId: number) => {
|
||||
const appDetails = await balenaApi.get({
|
||||
resource: 'application',
|
||||
id: appId,
|
||||
options: {
|
||||
$select: ['uuid'],
|
||||
},
|
||||
});
|
||||
|
||||
if (!appDetails || !appDetails.uuid) {
|
||||
throw new StatusError(404, `No app with id ${appId} found on the API.`);
|
||||
}
|
||||
|
||||
return appDetails.uuid;
|
||||
};
|
||||
|
||||
return (
|
||||
(
|
||||
await Promise.all(
|
||||
Object.keys(apps).map(
|
||||
async (id): Promise<[string, TargetApp]> => {
|
||||
const appId = parseInt(id, 10);
|
||||
const app = apps[appId];
|
||||
|
||||
// If local mode just use id as uuid
|
||||
const uuid = local ? id : await getUUIDFromAPI(appId);
|
||||
|
||||
const releases = app.commit
|
||||
? {
|
||||
[app.commit]: {
|
||||
id: app.releaseId,
|
||||
services: Object.keys(app.services ?? {})
|
||||
.map((serviceId) => {
|
||||
const {
|
||||
imageId,
|
||||
serviceName,
|
||||
image,
|
||||
environment,
|
||||
labels,
|
||||
running,
|
||||
serviceId: _serviceId,
|
||||
contract,
|
||||
...composition
|
||||
} = app.services[serviceId];
|
||||
|
||||
return [
|
||||
serviceName,
|
||||
{
|
||||
id: serviceId,
|
||||
image_id: imageId,
|
||||
image,
|
||||
environment,
|
||||
labels,
|
||||
running,
|
||||
contract,
|
||||
composition,
|
||||
},
|
||||
];
|
||||
})
|
||||
.reduce(
|
||||
(res, [serviceName, svc]) => ({
|
||||
...res,
|
||||
[serviceName]: svc,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
volumes: app.volumes ?? {},
|
||||
networks: app.networks ?? {},
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
return [
|
||||
uuid,
|
||||
{
|
||||
id: appId,
|
||||
name: app.name,
|
||||
class: 'fleet',
|
||||
releases,
|
||||
} as TargetApp,
|
||||
];
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
// Key by uuid
|
||||
.reduce((res, [uuid, app]) => ({ ...res, [uuid]: app }), {})
|
||||
);
|
||||
}
|
@ -6,265 +6,14 @@ import * as rimraf from 'rimraf';
|
||||
|
||||
const rimrafAsync = Bluebird.promisify(rimraf);
|
||||
|
||||
import * as apiBinder from '../api-binder';
|
||||
import * as config from '../config';
|
||||
import * as db from '../db';
|
||||
import * as volumeManager from '../compose/volume-manager';
|
||||
import * as serviceManager from '../compose/service-manager';
|
||||
import * as deviceState from '../device-state';
|
||||
import * as applicationManager from '../compose/application-manager';
|
||||
import * as constants from '../lib/constants';
|
||||
import {
|
||||
BackupError,
|
||||
DatabaseParseError,
|
||||
NotFoundError,
|
||||
InternalInconsistencyError,
|
||||
} from '../lib/errors';
|
||||
import { docker } from '../lib/docker-utils';
|
||||
import { BackupError, NotFoundError } from '../lib/errors';
|
||||
import { exec, pathExistsOnHost, mkdirp } from '../lib/fs-utils';
|
||||
import { log } from '../lib/supervisor-console';
|
||||
import type { AppsJsonFormat, TargetApp, TargetState } from '../types/state';
|
||||
import type { DatabaseApp } from '../device-state/target-state-cache';
|
||||
import { ShortString } from '../types';
|
||||
|
||||
export const defaultLegacyVolume = () => 'resin-data';
|
||||
|
||||
export function singleToMulticontainerApp(
|
||||
app: Dictionary<any>,
|
||||
): TargetApp & { appId: string } {
|
||||
const environment: Dictionary<string> = {};
|
||||
for (const key in app.env) {
|
||||
if (!/^RESIN_/.test(key)) {
|
||||
environment[key] = app.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
const { appId } = app;
|
||||
const conf = app.config != null ? app.config : {};
|
||||
const newApp: TargetApp & { appId: string } = {
|
||||
appId: appId.toString(),
|
||||
commit: app.commit,
|
||||
name: app.name,
|
||||
releaseId: 1,
|
||||
networks: {},
|
||||
volumes: {},
|
||||
services: {},
|
||||
};
|
||||
const defaultVolume = exports.defaultLegacyVolume();
|
||||
newApp.volumes[defaultVolume] = {};
|
||||
const updateStrategy =
|
||||
conf['RESIN_SUPERVISOR_UPDATE_STRATEGY'] != null
|
||||
? conf['RESIN_SUPERVISOR_UPDATE_STRATEGY']
|
||||
: 'download-then-kill';
|
||||
const handoverTimeout =
|
||||
conf['RESIN_SUPERVISOR_HANDOVER_TIMEOUT'] != null
|
||||
? conf['RESIN_SUPERVISOR_HANDOVER_TIMEOUT']
|
||||
: '';
|
||||
const restartPolicy =
|
||||
conf['RESIN_APP_RESTART_POLICY'] != null
|
||||
? conf['RESIN_APP_RESTART_POLICY']
|
||||
: 'always';
|
||||
newApp.services = {
|
||||
// Disable the next line, as this *has* to be a string
|
||||
// tslint:disable-next-line
|
||||
'1': {
|
||||
appId,
|
||||
serviceName: 'main' as ShortString,
|
||||
imageId: 1,
|
||||
commit: app.commit,
|
||||
releaseId: 1,
|
||||
image: app.imageId,
|
||||
privileged: true,
|
||||
networkMode: 'host',
|
||||
volumes: [`${defaultVolume}:/data`],
|
||||
labels: {
|
||||
'io.resin.features.kernel-modules': '1',
|
||||
'io.resin.features.firmware': '1',
|
||||
'io.resin.features.dbus': '1',
|
||||
'io.resin.features.supervisor-api': '1',
|
||||
'io.resin.features.resin-api': '1',
|
||||
'io.resin.update.strategy': updateStrategy,
|
||||
'io.resin.update.handover-timeout': handoverTimeout,
|
||||
'io.resin.legacy-container': '1',
|
||||
},
|
||||
environment,
|
||||
restart: restartPolicy,
|
||||
running: true,
|
||||
},
|
||||
};
|
||||
return newApp;
|
||||
}
|
||||
|
||||
export function convertLegacyAppsJson(appsArray: any[]): AppsJsonFormat {
|
||||
const deviceConfig = _.reduce(
|
||||
appsArray,
|
||||
(conf, app) => {
|
||||
return _.merge({}, conf, app.config);
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const apps = _.keyBy(_.map(appsArray, singleToMulticontainerApp), 'appId');
|
||||
return { apps, config: deviceConfig } as AppsJsonFormat;
|
||||
}
|
||||
|
||||
export async function normaliseLegacyDatabase() {
|
||||
await apiBinder.initialized;
|
||||
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
|
||||
log.info('Migrating ids for legacy app...');
|
||||
|
||||
const apps: DatabaseApp[] = await db.models('app').select();
|
||||
|
||||
if (apps.length === 0) {
|
||||
log.debug('No app to migrate');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const app of apps) {
|
||||
let services: Array<TargetApp['services']['']>;
|
||||
|
||||
try {
|
||||
services = JSON.parse(app.services);
|
||||
} catch (e) {
|
||||
throw new DatabaseParseError(e);
|
||||
}
|
||||
|
||||
// Check there's a main service, with legacy-container set
|
||||
if (services.length !== 1) {
|
||||
log.debug("App doesn't have a single service, ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
const service = services[0];
|
||||
if (
|
||||
!service.labels['io.resin.legacy-container'] &&
|
||||
!service.labels['io.balena.legacy-container']
|
||||
) {
|
||||
log.debug('Service is not marked as legacy, ignoring');
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug(`Getting release ${app.commit} for app ${app.appId} from API`);
|
||||
const releases = await apiBinder.balenaApi.get({
|
||||
resource: 'release',
|
||||
options: {
|
||||
$filter: {
|
||||
belongs_to__application: app.appId,
|
||||
commit: app.commit,
|
||||
status: 'success',
|
||||
},
|
||||
$expand: {
|
||||
contains__image: {
|
||||
$expand: 'image',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (releases.length === 0) {
|
||||
log.warn(
|
||||
`No compatible releases found in API, removing ${app.appId} from target state`,
|
||||
);
|
||||
await db.models('app').where({ appId: app.appId }).del();
|
||||
}
|
||||
|
||||
// We need to get the release.id, serviceId, image.id and updated imageUrl
|
||||
const release = releases[0];
|
||||
const image = release.contains__image[0].image[0];
|
||||
const serviceId = image.is_a_build_of__service.__id;
|
||||
const imageUrl = !image.content_hash
|
||||
? image.is_stored_at__image_location
|
||||
: `${image.is_stored_at__image_location}@${image.content_hash}`;
|
||||
|
||||
log.debug(
|
||||
`Found a release with releaseId ${release.id}, imageId ${image.id}, serviceId ${serviceId}\nImage location is ${imageUrl}`,
|
||||
);
|
||||
|
||||
const imageFromDocker = await docker
|
||||
.getImage(service.image)
|
||||
.inspect()
|
||||
.catch((error) => {
|
||||
if (error instanceof NotFoundError) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
const imagesFromDatabase = await db
|
||||
.models('image')
|
||||
.where({ name: service.image })
|
||||
.select();
|
||||
|
||||
await db.transaction(async (trx: db.Transaction) => {
|
||||
try {
|
||||
if (imagesFromDatabase.length > 0) {
|
||||
log.debug('Deleting existing image entry in db');
|
||||
await trx('image').where({ name: service.image }).del();
|
||||
} else {
|
||||
log.debug('No image in db to delete');
|
||||
}
|
||||
} finally {
|
||||
if (imageFromDocker != null) {
|
||||
log.debug('Inserting fixed image entry in db');
|
||||
await trx('image').insert({
|
||||
name: imageUrl,
|
||||
appId: app.appId,
|
||||
serviceId,
|
||||
serviceName: service.serviceName,
|
||||
imageId: image.id,
|
||||
releaseId: release.id,
|
||||
dependent: 0,
|
||||
dockerImageId: imageFromDocker.Id,
|
||||
});
|
||||
} else {
|
||||
log.debug('Image is not downloaded, so not saving it to db');
|
||||
}
|
||||
|
||||
delete service.labels['io.resin.legacy-container'];
|
||||
delete service.labels['io.balena.legacy-container'];
|
||||
|
||||
Object.assign(app, {
|
||||
services: JSON.stringify([
|
||||
Object.assign(service, {
|
||||
image: imageUrl,
|
||||
serviceID: serviceId,
|
||||
imageId: image.id,
|
||||
releaseId: release.id,
|
||||
}),
|
||||
]),
|
||||
releaseId: release.id,
|
||||
});
|
||||
|
||||
log.debug('Updating app entry in db');
|
||||
log.success('Successfully migrated legacy application');
|
||||
await trx('app').update(app).where({ appId: app.appId });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
log.debug('Killing legacy containers');
|
||||
await serviceManager.killAllLegacy();
|
||||
log.debug('Migrating legacy app volumes');
|
||||
|
||||
await applicationManager.initialized;
|
||||
const targetApps = await applicationManager.getTargetApps();
|
||||
|
||||
for (const appId of _.keys(targetApps)) {
|
||||
await volumeManager.createFromLegacy(parseInt(appId, 10));
|
||||
}
|
||||
|
||||
await config.set({
|
||||
legacyAppsPresent: false,
|
||||
});
|
||||
}
|
||||
import { TargetState } from '../types';
|
||||
|
||||
export async function loadBackupFromMigration(
|
||||
targetState: TargetState,
|
||||
@ -281,14 +30,17 @@ export async function loadBackupFromMigration(
|
||||
|
||||
await deviceState.setTarget(targetState);
|
||||
|
||||
// multi-app warning!
|
||||
const appId = parseInt(_.keys(targetState.local?.apps)[0], 10);
|
||||
// TODO: this code is only single-app compatible
|
||||
const [uuid] = Object.keys(targetState.local?.apps);
|
||||
|
||||
if (isNaN(appId)) {
|
||||
throw new BackupError('No appId in target state');
|
||||
if (!!uuid) {
|
||||
throw new BackupError('No apps in the target state');
|
||||
}
|
||||
|
||||
const volumes = targetState.local?.apps?.[appId].volumes;
|
||||
const { id: appId } = targetState.local?.apps[uuid];
|
||||
const [release] = Object.values(targetState.local?.apps[uuid].releases);
|
||||
|
||||
const volumes = release?.volumes ?? {};
|
||||
|
||||
const backupPath = path.join(constants.rootMountPoint, 'mnt/data/backup');
|
||||
// We clear this path in case it exists from an incomplete run of this function
|
||||
|
64
src/lib/supervisor-metadata.ts
Normal file
64
src/lib/supervisor-metadata.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import * as memoizee from 'memoizee';
|
||||
import * as config from '../config';
|
||||
import { InternalInconsistencyError } from './errors';
|
||||
|
||||
export type SupervisorMetadata = {
|
||||
uuid: string;
|
||||
serviceName: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Although it might feel unsettling to hardcode these ids here.
|
||||
* the main purpose of app uuids is to have environment independent
|
||||
* apps. These ids will be the same in balena-cloud.com and balena-staging.com
|
||||
* and they should be the same in open-balena instances for target state
|
||||
* v3 to work with those instances.
|
||||
*
|
||||
* This will only be necessary until the supervisor becomes an actual app
|
||||
* on balena
|
||||
*/
|
||||
const SUPERVISOR_APPS: { [arch: string]: SupervisorMetadata } = {
|
||||
amd64: {
|
||||
uuid: '52e35121417640b1b28a680504e4039b',
|
||||
serviceName: 'balena-supervisor',
|
||||
},
|
||||
aarch64: {
|
||||
uuid: '900de4f3cbac4b9bbd232885a35e407b',
|
||||
serviceName: 'balena-supervisor',
|
||||
},
|
||||
armv7hf: {
|
||||
uuid: '2e66a95795c149959c69472a8c2f92b8',
|
||||
serviceName: 'balena-supervisor',
|
||||
},
|
||||
i386: {
|
||||
uuid: '531b357e155c480cbec0fdd33041a1f5',
|
||||
serviceName: 'balena-supervisor',
|
||||
},
|
||||
rpi: {
|
||||
uuid: '6822565f766e413e96d9bebe2227cdcc',
|
||||
serviceName: 'balena-supervisor',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the metadata from the supervisor container
|
||||
*
|
||||
* This is needed for the supervisor to identify itself on the target
|
||||
* state and on getStatus() in device-state.ts
|
||||
*
|
||||
* TODO: remove this once the supervisor knows how to update itself
|
||||
*/
|
||||
export const getSupervisorMetadata = memoizee(
|
||||
async () => {
|
||||
const deviceArch = await config.get('deviceArch');
|
||||
const meta: SupervisorMetadata = SUPERVISOR_APPS[deviceArch];
|
||||
if (meta == null) {
|
||||
throw new InternalInconsistencyError(
|
||||
`Unknown device architecture ${deviceArch}. Could not find matching supervisor metadata.`,
|
||||
);
|
||||
}
|
||||
|
||||
return meta;
|
||||
},
|
||||
{ promise: true },
|
||||
);
|
56
src/migrations/M00008.js
Normal file
56
src/migrations/M00008.js
Normal file
@ -0,0 +1,56 @@
|
||||
export async function up(knex) {
|
||||
await knex.schema.table('app', (table) => {
|
||||
table.string('uuid');
|
||||
table.unique('uuid');
|
||||
table.string('class').defaultTo('fleet');
|
||||
table.boolean('isHost').defaultTo(false);
|
||||
});
|
||||
|
||||
await knex.schema.table('image', (table) => {
|
||||
table.string('appUuid');
|
||||
table.string('commit');
|
||||
});
|
||||
|
||||
const legacyAppsPresent = await knex('config')
|
||||
.where({ key: 'legacyAppsPresent' })
|
||||
.select('value');
|
||||
|
||||
// If there are legacy apps we let the database normalization function
|
||||
// populate the correct values
|
||||
if (legacyAppsPresent && legacyAppsPresent.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise delete cloud target apps and images in the database so they can get repopulated
|
||||
// with the uuid from the target state. Removing the `targetStateSet` configuration ensures that
|
||||
// the supervisor will maintain the current state and will only apply the new target once it gets
|
||||
// a new cloud copy, which should include the proper metadata
|
||||
await knex('image').del();
|
||||
await knex('app').whereNot({ source: 'local' }).del();
|
||||
await knex('config').where({ key: 'targetStateSet' }).del();
|
||||
|
||||
const apps = await knex('app').select();
|
||||
|
||||
// For remaining local apps, if any, the appUuid is not that relevant, so just
|
||||
// use appId to prevent the app from getting uninstalled. Adding the appUuid will restart
|
||||
// the app though
|
||||
await Promise.all(
|
||||
apps.map((app) => {
|
||||
const services = JSON.parse(app.services).map((svc) => ({
|
||||
...svc,
|
||||
appUuid: app.appId.toString(),
|
||||
}));
|
||||
|
||||
return knex('app')
|
||||
.where({ id: app.id })
|
||||
.update({
|
||||
uuid: app.appId.toString(),
|
||||
services: JSON.stringify(services),
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function down() {
|
||||
throw new Error('Not Implemented');
|
||||
}
|
@ -4,7 +4,7 @@ import * as config from './config';
|
||||
import * as deviceState from './device-state';
|
||||
import * as eventTracker from './event-tracker';
|
||||
import { intialiseContractRequirements } from './lib/contracts';
|
||||
import { normaliseLegacyDatabase } from './lib/migration';
|
||||
import { normaliseLegacyDatabase } from './lib/legacy';
|
||||
import * as osRelease from './lib/os-release';
|
||||
import * as logger from './logger';
|
||||
import SupervisorAPI from './supervisor-api';
|
||||
|
@ -1,6 +1,6 @@
|
||||
import * as t from 'io-ts';
|
||||
import { chain } from 'fp-ts/lib/Either';
|
||||
import { pipe } from 'fp-ts/function';
|
||||
import { chain, fold, isRight, left, right, Either } from 'fp-ts/lib/Either';
|
||||
import { pipe, flow } from 'fp-ts/function';
|
||||
|
||||
/**
|
||||
* A short string is a non null string between
|
||||
@ -184,3 +184,50 @@ export const DeviceName = new t.Type<string, string>(
|
||||
);
|
||||
|
||||
export type DeviceName = t.TypeOf<typeof DeviceName>;
|
||||
|
||||
/**
|
||||
* Creates a record type that checks for constraints on the record elements
|
||||
*/
|
||||
const restrictedRecord = <
|
||||
K extends t.Mixed,
|
||||
V extends t.Mixed,
|
||||
R extends { [key in t.TypeOf<K>]: t.TypeOf<V> }
|
||||
>(
|
||||
k: K,
|
||||
v: V,
|
||||
test: (i: R) => Either<string, R>,
|
||||
name = 'RestrictedRecord',
|
||||
) => {
|
||||
return new t.Type<R>(
|
||||
name,
|
||||
(i): i is R => t.record(k, v).is(i) && isRight(test(i as R)),
|
||||
(i, c) =>
|
||||
pipe(
|
||||
// pipe takes the first result and passes it through rest of the function arguments
|
||||
t.record(k, v).validate(i, c), // validate that the element is a proper record first (returns an Either)
|
||||
chain(
|
||||
// chain takes a function (a) => Either and converts it into a function (Either) => (Either)
|
||||
flow(
|
||||
// flow creates a function composition
|
||||
test, // receives a record and returns Either<string,R>
|
||||
fold((m) => t.failure(i, c, m), t.success), // fold converts Either<string,R> to an Either<Errors, R>
|
||||
),
|
||||
),
|
||||
),
|
||||
t.identity,
|
||||
);
|
||||
};
|
||||
|
||||
export const nonEmptyRecord = <K extends t.Mixed, V extends t.Mixed>(
|
||||
k: K,
|
||||
v: V,
|
||||
) =>
|
||||
restrictedRecord(
|
||||
k,
|
||||
v,
|
||||
(o) =>
|
||||
Object.keys(o).length > 0
|
||||
? right(o)
|
||||
: left('must have at least 1 element'),
|
||||
'NonEmptyRecord',
|
||||
);
|
||||
|
@ -2,22 +2,21 @@ import * as t from 'io-ts';
|
||||
|
||||
// TODO: move all these exported types to ../compose/types
|
||||
import { ComposeNetworkConfig } from '../compose/types/network';
|
||||
import { ServiceComposeConfig } from '../compose/types/service';
|
||||
import { ComposeVolumeConfig } from '../compose/volume';
|
||||
|
||||
import {
|
||||
DockerName,
|
||||
EnvVarObject,
|
||||
LabelObject,
|
||||
StringIdentifier,
|
||||
NumericIdentifier,
|
||||
ShortString,
|
||||
DeviceName,
|
||||
nonEmptyRecord,
|
||||
} from './basic';
|
||||
|
||||
import App from '../compose/app';
|
||||
|
||||
export type DeviceReportFields = Partial<{
|
||||
export type DeviceLegacyReport = Partial<{
|
||||
api_port: number;
|
||||
api_secret: string | null;
|
||||
ip_address: string;
|
||||
@ -30,15 +29,15 @@ export type DeviceReportFields = Partial<{
|
||||
update_failed: boolean;
|
||||
update_pending: boolean;
|
||||
update_downloaded: boolean;
|
||||
is_on__commit: string;
|
||||
logs_channel: null;
|
||||
logs_channel: string | null;
|
||||
mac_address: string | null;
|
||||
}>;
|
||||
|
||||
// This is the state that is sent to the cloud
|
||||
export interface DeviceStatus {
|
||||
export interface DeviceLegacyState {
|
||||
local?: {
|
||||
config?: Dictionary<string>;
|
||||
is_on__commit?: string;
|
||||
apps?: {
|
||||
[appId: string]: {
|
||||
services: {
|
||||
@ -50,7 +49,7 @@ export interface DeviceStatus {
|
||||
};
|
||||
};
|
||||
};
|
||||
} & DeviceReportFields;
|
||||
} & DeviceLegacyReport;
|
||||
// TODO: Type the dependent entry correctly
|
||||
dependent?: {
|
||||
[key: string]: any;
|
||||
@ -58,6 +57,71 @@ export interface DeviceStatus {
|
||||
commit?: string;
|
||||
}
|
||||
|
||||
export type ServiceState = {
|
||||
image: string;
|
||||
status: string;
|
||||
download_progress?: number | null;
|
||||
};
|
||||
|
||||
export type ReleaseState = {
|
||||
services: {
|
||||
[serviceName: string]: ServiceState;
|
||||
};
|
||||
};
|
||||
|
||||
export type ReleasesState = {
|
||||
[releaseUuid: string]: ReleaseState;
|
||||
};
|
||||
|
||||
export type AppState = {
|
||||
release_uuid?: string;
|
||||
releases: ReleasesState;
|
||||
};
|
||||
|
||||
export type DeviceReport = {
|
||||
name?: string;
|
||||
status?: string;
|
||||
os_version?: string | null; // TODO: Should these purely come from the os app?
|
||||
os_variant?: string | null; // TODO: Should these purely come from the os app?
|
||||
supervisor_version?: string; // TODO: Should this purely come from the supervisor app?
|
||||
provisioning_progress?: number | null; // TODO: should this be reported as part of the os app?
|
||||
provisioning_state?: string; // TODO: should this be reported as part of the os app?
|
||||
ip_address?: string;
|
||||
mac_address?: string | null;
|
||||
api_port?: number; // TODO: should this be reported as part of the supervisor app?
|
||||
api_secret?: string | null; // TODO: should this be reported as part of the supervisor app?
|
||||
logs_channel?: string | null; // TODO: should this be reported as part of the supervisor app? or should it not be reported anymore at all?
|
||||
memory_usage?: number;
|
||||
memory_total?: number;
|
||||
storage_block_device?: string;
|
||||
storage_usage?: number;
|
||||
storage_total?: number;
|
||||
cpu_temp?: number;
|
||||
cpu_usage?: number;
|
||||
cpu_id?: string;
|
||||
is_undervolted?: boolean;
|
||||
// TODO: these are ignored by the API but are used by supervisor local API
|
||||
update_failed?: boolean;
|
||||
update_pending?: boolean;
|
||||
update_downloaded?: boolean;
|
||||
};
|
||||
|
||||
export type DeviceState = {
|
||||
[deviceUuid: string]: DeviceReport & {
|
||||
/**
|
||||
* Used for setting dependent devices as online
|
||||
*/
|
||||
is_online?: boolean;
|
||||
/**
|
||||
* Used for setting gateway device of dependent devices
|
||||
*/
|
||||
parent_device?: number;
|
||||
apps?: {
|
||||
[appUuid: string]: AppState;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// Return a type with a default value
|
||||
const withDefault = <T extends t.Any>(
|
||||
type: T,
|
||||
@ -107,10 +171,23 @@ const fromType = <T extends object>(name: string) =>
|
||||
t.identity,
|
||||
);
|
||||
|
||||
// Alias short string to UUID so code reads more clearly
|
||||
export const UUID = ShortString;
|
||||
|
||||
/**
|
||||
* A target service has docker image, a set of environment variables
|
||||
* and labels as well as one or more configurations
|
||||
*/
|
||||
export const TargetService = t.intersection([
|
||||
t.type({
|
||||
serviceName: DockerName,
|
||||
imageId: NumericIdentifier,
|
||||
/**
|
||||
* @deprecated to be removed in state v4
|
||||
*/
|
||||
id: NumericIdentifier,
|
||||
/**
|
||||
* @deprecated to be removed in state v4
|
||||
*/
|
||||
image_id: NumericIdentifier,
|
||||
image: ShortString,
|
||||
environment: EnvVarObject,
|
||||
labels: LabelObject,
|
||||
@ -118,112 +195,164 @@ export const TargetService = t.intersection([
|
||||
t.partial({
|
||||
running: withDefault(t.boolean, true),
|
||||
contract: t.record(t.string, t.unknown),
|
||||
// This will not be validated
|
||||
// TODO: convert ServiceComposeConfig to a io-ts type
|
||||
composition: t.record(t.string, t.unknown),
|
||||
}),
|
||||
// This will not be validated
|
||||
// TODO: convert ServiceComposeConfig to a io-ts type
|
||||
fromType<ServiceComposeConfig>('ServiceComposition'),
|
||||
]);
|
||||
export type TargetService = t.TypeOf<typeof TargetService>;
|
||||
|
||||
/**
|
||||
* Target state release format
|
||||
*/
|
||||
export const TargetRelease = t.type({
|
||||
/**
|
||||
* @deprecated to be removed in state v4
|
||||
*/
|
||||
id: NumericIdentifier,
|
||||
services: withDefault(t.record(DockerName, TargetService), {}),
|
||||
volumes: withDefault(
|
||||
t.record(
|
||||
DockerName,
|
||||
// TargetVolume format will NOT be validated
|
||||
// TODO: convert ComposeVolumeConfig to a io-ts type
|
||||
fromType<Partial<ComposeVolumeConfig>>('Volume'),
|
||||
),
|
||||
{},
|
||||
),
|
||||
networks: withDefault(
|
||||
t.record(
|
||||
DockerName,
|
||||
// TargetNetwork format will NOT be validated
|
||||
// TODO: convert ComposeVolumeConfig to a io-ts type
|
||||
fromType<Partial<ComposeNetworkConfig>>('Network'),
|
||||
),
|
||||
{},
|
||||
),
|
||||
});
|
||||
export type TargetRelease = t.TypeOf<typeof TargetRelease>;
|
||||
|
||||
export const TargetAppClass = t.union([
|
||||
t.literal('fleet'),
|
||||
t.literal('app'),
|
||||
t.literal('block'),
|
||||
]);
|
||||
export type TargetAppClass = t.TypeOf<typeof TargetAppClass>;
|
||||
|
||||
/**
|
||||
* A target app is composed by a release and a collection of volumes and
|
||||
* networks.
|
||||
*/
|
||||
const TargetApp = t.intersection(
|
||||
[
|
||||
t.type({
|
||||
/**
|
||||
* @deprecated to be removed in state v4
|
||||
*/
|
||||
id: NumericIdentifier,
|
||||
name: ShortString,
|
||||
services: withDefault(t.record(StringIdentifier, TargetService), {}),
|
||||
volumes: withDefault(
|
||||
t.record(
|
||||
DockerName,
|
||||
// TargetVolume format will NOT be validated
|
||||
// TODO: convert ComposeVolumeConfig to a io-ts type
|
||||
fromType<Partial<ComposeVolumeConfig>>('Volume'),
|
||||
),
|
||||
{},
|
||||
),
|
||||
networks: withDefault(
|
||||
t.record(
|
||||
DockerName,
|
||||
// TargetNetwork format will NOT be validated
|
||||
// TODO: convert ComposeVolumeConfig to a io-ts type
|
||||
fromType<Partial<ComposeNetworkConfig>>('Network'),
|
||||
),
|
||||
{},
|
||||
),
|
||||
// There should be only one fleet class app in the target state but we
|
||||
// are not validating that here
|
||||
class: withDefault(TargetAppClass, 'fleet'),
|
||||
// TODO: target release must have at most one value. Should we validate?
|
||||
releases: withDefault(t.record(UUID, TargetRelease), {}),
|
||||
}),
|
||||
t.partial({
|
||||
commit: ShortString,
|
||||
releaseId: NumericIdentifier,
|
||||
parent_app: UUID,
|
||||
is_host: t.boolean,
|
||||
}),
|
||||
],
|
||||
'App',
|
||||
);
|
||||
export type TargetApp = t.TypeOf<typeof TargetApp>;
|
||||
|
||||
export const TargetApps = t.record(StringIdentifier, TargetApp);
|
||||
export const TargetApps = t.record(UUID, TargetApp);
|
||||
export type TargetApps = t.TypeOf<typeof TargetApps>;
|
||||
|
||||
const DependentApp = t.intersection(
|
||||
[
|
||||
t.type({
|
||||
name: ShortString,
|
||||
parentApp: NumericIdentifier,
|
||||
config: EnvVarObject,
|
||||
}),
|
||||
t.partial({
|
||||
releaseId: NumericIdentifier,
|
||||
imageId: NumericIdentifier,
|
||||
commit: ShortString,
|
||||
image: ShortString,
|
||||
}),
|
||||
],
|
||||
'DependentApp',
|
||||
);
|
||||
|
||||
const DependentDevice = t.type(
|
||||
{
|
||||
name: ShortString, // device uuid
|
||||
apps: t.record(
|
||||
StringIdentifier,
|
||||
t.type({ config: EnvVarObject, environment: EnvVarObject }),
|
||||
),
|
||||
},
|
||||
'DependentDevice',
|
||||
);
|
||||
|
||||
// Although the original types for dependent apps and dependent devices was a dictionary,
|
||||
// proxyvisor.js accepts both a dictionary and an array. Unfortunately
|
||||
// the CLI sends an array, thus the types need to accept both
|
||||
const DependentApps = t.union([
|
||||
t.array(DependentApp),
|
||||
t.record(StringIdentifier, DependentApp),
|
||||
]);
|
||||
|
||||
const DependentDevices = t.union([
|
||||
t.array(DependentDevice),
|
||||
t.record(StringIdentifier, DependentDevice),
|
||||
]);
|
||||
|
||||
export const TargetState = t.type({
|
||||
local: t.type({
|
||||
/**
|
||||
* A device has a name, config and collection of apps
|
||||
*/
|
||||
const TargetDevice = t.intersection([
|
||||
t.type({
|
||||
name: DeviceName,
|
||||
config: EnvVarObject,
|
||||
apps: TargetApps,
|
||||
}),
|
||||
dependent: t.type({
|
||||
apps: DependentApps,
|
||||
devices: DependentDevices,
|
||||
t.partial({
|
||||
parent_device: UUID,
|
||||
}),
|
||||
});
|
||||
]);
|
||||
export type TargetDevice = t.TypeOf<typeof TargetDevice>;
|
||||
|
||||
/**
|
||||
* Target state is a collection of devices one local device
|
||||
* (with uuid matching the one in config.json) and zero or more dependent
|
||||
* devices
|
||||
*
|
||||
*
|
||||
* When all io-ts types are composed, the final type of the target state
|
||||
* is the one given by the following description
|
||||
* ```
|
||||
* {
|
||||
* [uuid: string]: {
|
||||
* name: string;
|
||||
* parent_device?: string;
|
||||
* config?: {
|
||||
* [varName: string]: string;
|
||||
* };
|
||||
* apps: {
|
||||
* [uuid: string]: {
|
||||
* // @deprecated to be removed in state v4
|
||||
* id: number;
|
||||
* name: string;
|
||||
* class: 'fleet' | 'block' | 'app';
|
||||
* parent_app?: string;
|
||||
* is_host?: boolean;
|
||||
* releases?: {
|
||||
* [uuid: string]: {
|
||||
* // @deprecated to be removed in state v4
|
||||
* id: number;
|
||||
* services?: {
|
||||
* [name: string]: {
|
||||
* // @deprecated to be removed in state v4
|
||||
* id: number;
|
||||
* // @deprecated to be removed in state v4
|
||||
* image_id: number;
|
||||
* image: string;
|
||||
* // defaults to true if undefined
|
||||
* running?: boolean;
|
||||
* environment: {
|
||||
* [varName: string]: string;
|
||||
* };
|
||||
* labels: {
|
||||
* [labelName: string]: string;
|
||||
* };
|
||||
* contract?: AnyObject;
|
||||
* composition?: ServiceComposition;
|
||||
* };
|
||||
* };
|
||||
* volumes?: AnyObject;
|
||||
* networks?: AnyObject;
|
||||
* };
|
||||
* };
|
||||
* };
|
||||
* };
|
||||
* };
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const TargetState = t.record(UUID, TargetDevice);
|
||||
export type TargetState = t.TypeOf<typeof TargetState>;
|
||||
|
||||
const TargetAppWithRelease = t.intersection([
|
||||
TargetApp,
|
||||
t.type({ commit: t.string, releaseId: NumericIdentifier }),
|
||||
t.type({ releases: nonEmptyRecord(UUID, TargetRelease) }),
|
||||
]);
|
||||
|
||||
const AppsJsonFormat = t.intersection([
|
||||
export const AppsJsonFormat = t.intersection([
|
||||
t.type({
|
||||
config: EnvVarObject,
|
||||
apps: t.record(StringIdentifier, TargetAppWithRelease),
|
||||
config: withDefault(EnvVarObject, {}),
|
||||
apps: withDefault(t.record(UUID, TargetAppWithRelease), {}),
|
||||
}),
|
||||
t.partial({ pinDevice: t.boolean }),
|
||||
]);
|
||||
|
@ -1,12 +1,10 @@
|
||||
import * as _ from 'lodash';
|
||||
import { SinonStub, stub } from 'sinon';
|
||||
import { expect } from 'chai';
|
||||
import * as sinon from 'sinon';
|
||||
|
||||
import { StatusCodeError, UpdatesLockedError } from '../src/lib/errors';
|
||||
import prepare = require('./lib/prepare');
|
||||
import * as dockerUtils from '../src/lib/docker-utils';
|
||||
import * as config from '../src/config';
|
||||
import * as images from '../src/compose/images';
|
||||
import * as imageManager from '../src/compose/images';
|
||||
import { ConfigTxt } from '../src/config/backends/config-txt';
|
||||
import * as deviceState from '../src/device-state';
|
||||
import * as deviceConfig from '../src/device-config';
|
||||
@ -18,6 +16,10 @@ import Service from '../src/compose/service';
|
||||
import { intialiseContractRequirements } from '../src/lib/contracts';
|
||||
import * as updateLock from '../src/lib/update-lock';
|
||||
import * as fsUtils from '../src/lib/fs-utils';
|
||||
import { TargetState } from '../src/types';
|
||||
|
||||
import * as dbHelper from './lib/db-helper';
|
||||
import log from '../src/lib/supervisor-console';
|
||||
|
||||
const mockedInitialConfig = {
|
||||
RESIN_SUPERVISOR_CONNECTIVITY_CHECK: 'true',
|
||||
@ -35,147 +37,37 @@ const mockedInitialConfig = {
|
||||
RESIN_SUPERVISOR_VPN_CONTROL: 'true',
|
||||
};
|
||||
|
||||
const testTarget2 = {
|
||||
local: {
|
||||
name: 'aDeviceWithDifferentName',
|
||||
config: {
|
||||
RESIN_HOST_CONFIG_gpu_mem: '512',
|
||||
},
|
||||
apps: {
|
||||
'1234': {
|
||||
name: 'superapp',
|
||||
commit: 'afafafa',
|
||||
releaseId: 2,
|
||||
services: {
|
||||
'23': {
|
||||
serviceName: 'aservice',
|
||||
imageId: 12345,
|
||||
image: 'registry2.resin.io/superapp/edfabc',
|
||||
environment: {
|
||||
FOO: 'bar',
|
||||
},
|
||||
labels: {},
|
||||
},
|
||||
'24': {
|
||||
serviceName: 'anotherService',
|
||||
imageId: 12346,
|
||||
image: 'registry2.resin.io/superapp/afaff',
|
||||
environment: {
|
||||
FOO: 'bro',
|
||||
},
|
||||
labels: {},
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
dependent: { apps: {}, devices: {} },
|
||||
};
|
||||
|
||||
const testTargetWithDefaults2 = {
|
||||
local: {
|
||||
name: 'aDeviceWithDifferentName',
|
||||
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: {
|
||||
'1234': {
|
||||
appId: 1234,
|
||||
name: 'superapp',
|
||||
commit: 'afafafa',
|
||||
releaseId: 2,
|
||||
services: [
|
||||
_.merge(
|
||||
{ appId: 1234, serviceId: 23, releaseId: 2 },
|
||||
_.clone(testTarget2.local.apps['1234'].services['23']),
|
||||
),
|
||||
_.merge(
|
||||
{ appId: 1234, serviceId: 24, releaseId: 2 },
|
||||
_.clone(testTarget2.local.apps['1234'].services['24']),
|
||||
),
|
||||
],
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const testTargetInvalid = {
|
||||
local: {
|
||||
name: 'aDeviceWithDifferentName',
|
||||
config: {
|
||||
RESIN_HOST_CONFIG_gpu_mem: '512',
|
||||
},
|
||||
apps: {
|
||||
1234: {
|
||||
appId: '1234',
|
||||
name: 'superapp',
|
||||
commit: 'afafafa',
|
||||
releaseId: '2',
|
||||
config: {},
|
||||
services: {
|
||||
23: {
|
||||
serviceId: '23',
|
||||
serviceName: 'aservice',
|
||||
imageId: '12345',
|
||||
image: 'registry2.resin.io/superapp/edfabc',
|
||||
config: {},
|
||||
environment: {
|
||||
' FOO': 'bar',
|
||||
},
|
||||
labels: {},
|
||||
},
|
||||
24: {
|
||||
serviceId: '24',
|
||||
serviceName: 'anotherService',
|
||||
imageId: '12346',
|
||||
image: 'registry2.resin.io/superapp/afaff',
|
||||
config: {},
|
||||
environment: {
|
||||
FOO: 'bro',
|
||||
},
|
||||
labels: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dependent: { apps: {}, devices: {} },
|
||||
};
|
||||
|
||||
describe('deviceState', () => {
|
||||
let source: string;
|
||||
const originalImagesSave = images.save;
|
||||
const originalImagesInspect = images.inspectByName;
|
||||
describe('device-state', () => {
|
||||
const originalImagesSave = imageManager.save;
|
||||
const originalImagesInspect = imageManager.inspectByName;
|
||||
const originalGetCurrent = deviceConfig.getCurrent;
|
||||
|
||||
let testDb: dbHelper.TestDatabase;
|
||||
|
||||
before(async () => {
|
||||
await prepare();
|
||||
testDb = await dbHelper.createDB();
|
||||
|
||||
await config.initialized;
|
||||
|
||||
// Prevent side effects from changes in config
|
||||
sinon.stub(config, 'on');
|
||||
|
||||
// Set the device uuid
|
||||
await config.set({ uuid: 'local' });
|
||||
|
||||
await deviceState.initialized;
|
||||
|
||||
source = await config.get('apiEndpoint');
|
||||
// disable log output during testing
|
||||
sinon.stub(log, 'debug');
|
||||
sinon.stub(log, 'warn');
|
||||
sinon.stub(log, 'info');
|
||||
sinon.stub(log, 'event');
|
||||
sinon.stub(log, 'success');
|
||||
|
||||
stub(Service as any, 'extendEnvVars').callsFake((env) => {
|
||||
// TODO: all these stubs are internal implementation details of
|
||||
// deviceState, we should refactor deviceState to use dependency
|
||||
// injection instead of initializing everything in memory
|
||||
sinon.stub(Service as any, 'extendEnvVars').callsFake((env: any) => {
|
||||
env['ADDITIONAL_ENV_VAR'] = 'foo';
|
||||
return env;
|
||||
});
|
||||
@ -185,20 +77,20 @@ describe('deviceState', () => {
|
||||
deviceType: 'intel-nuc',
|
||||
});
|
||||
|
||||
stub(dockerUtils, 'getNetworkGateway').returns(
|
||||
Promise.resolve('172.17.0.1'),
|
||||
);
|
||||
sinon
|
||||
.stub(dockerUtils, 'getNetworkGateway')
|
||||
.returns(Promise.resolve('172.17.0.1'));
|
||||
|
||||
// @ts-expect-error Assigning to a RO property
|
||||
images.cleanImageData = () => {
|
||||
imageManager.cleanImageData = () => {
|
||||
console.log('Cleanup database called');
|
||||
};
|
||||
|
||||
// @ts-expect-error Assigning to a RO property
|
||||
images.save = () => Promise.resolve();
|
||||
imageManager.save = () => Promise.resolve();
|
||||
|
||||
// @ts-expect-error Assigning to a RO property
|
||||
images.inspectByName = () => {
|
||||
imageManager.inspectByName = () => {
|
||||
const err: StatusCodeError = new Error();
|
||||
err.statusCode = 404;
|
||||
return Promise.reject(err);
|
||||
@ -211,20 +103,27 @@ describe('deviceState', () => {
|
||||
deviceConfig.getCurrent = async () => mockedInitialConfig;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
after(async () => {
|
||||
(Service as any).extendEnvVars.restore();
|
||||
(dockerUtils.getNetworkGateway as sinon.SinonStub).restore();
|
||||
|
||||
// @ts-expect-error Assigning to a RO property
|
||||
images.save = originalImagesSave;
|
||||
imageManager.save = originalImagesSave;
|
||||
// @ts-expect-error Assigning to a RO property
|
||||
images.inspectByName = originalImagesInspect;
|
||||
imageManager.inspectByName = originalImagesInspect;
|
||||
// @ts-expect-error Assigning to a RO property
|
||||
deviceConfig.getCurrent = originalGetCurrent;
|
||||
|
||||
try {
|
||||
await testDb.destroy();
|
||||
} catch (e) {
|
||||
/* noop */
|
||||
}
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await prepare();
|
||||
afterEach(async () => {
|
||||
await testDb.reset();
|
||||
});
|
||||
|
||||
it('loads a target state from an apps.json file and saves it as target state, then returns it', async () => {
|
||||
@ -233,6 +132,11 @@ describe('deviceState', () => {
|
||||
const targetState = await deviceState.getTarget();
|
||||
expect(await fsUtils.exists(appsJsonBackup(appsJson))).to.be.true;
|
||||
|
||||
expect(targetState)
|
||||
.to.have.property('local')
|
||||
.that.has.property('config')
|
||||
.that.has.property('HOST_CONFIG_gpu_mem')
|
||||
.that.equals('256');
|
||||
expect(targetState)
|
||||
.to.have.property('local')
|
||||
.that.has.property('apps')
|
||||
@ -308,45 +212,140 @@ describe('deviceState', () => {
|
||||
});
|
||||
|
||||
it('emits a change event when a new state is reported', (done) => {
|
||||
// TODO: where is the test on this test?
|
||||
deviceState.once('change', done);
|
||||
deviceState.reportCurrentState({ someStateDiff: 'someValue' } as any);
|
||||
});
|
||||
|
||||
it.skip('writes the target state to the db with some extra defaults', async () => {
|
||||
const testTarget = _.cloneDeep(testTargetWithDefaults2);
|
||||
it('writes the target state to the db with some extra defaults', async () => {
|
||||
await deviceState.setTarget({
|
||||
local: {
|
||||
name: 'aDeviceWithDifferentName',
|
||||
config: {
|
||||
RESIN_HOST_CONFIG_gpu_mem: '512',
|
||||
},
|
||||
apps: {
|
||||
myapp: {
|
||||
id: 1234,
|
||||
name: 'superapp',
|
||||
class: 'fleet',
|
||||
releases: {
|
||||
afafafa: {
|
||||
id: 2,
|
||||
services: {
|
||||
aservice: {
|
||||
id: 23,
|
||||
image_id: 12345,
|
||||
image: 'registry2.resin.io/superapp/edfabc',
|
||||
environment: {
|
||||
FOO: 'bar',
|
||||
},
|
||||
labels: {},
|
||||
},
|
||||
anotherService: {
|
||||
id: 24,
|
||||
image_id: 12346,
|
||||
image: 'registry2.resin.io/superapp/afaff',
|
||||
environment: {
|
||||
FOO: 'bro',
|
||||
},
|
||||
labels: {},
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as TargetState);
|
||||
const targetState = await deviceState.getTarget();
|
||||
|
||||
const services: Service[] = [];
|
||||
for (const service of testTarget.local.apps['1234'].services) {
|
||||
const imageName = images.normalise(service.image);
|
||||
service.image = imageName;
|
||||
(service as any).imageName = imageName;
|
||||
services.push(
|
||||
await Service.fromComposeObject(service, {
|
||||
appName: 'supertest',
|
||||
} as any),
|
||||
);
|
||||
}
|
||||
expect(targetState)
|
||||
.to.have.property('local')
|
||||
.that.has.property('config')
|
||||
.that.has.property('HOST_CONFIG_gpu_mem')
|
||||
.that.equals('512');
|
||||
|
||||
(testTarget as any).local.apps['1234'].services = _.keyBy(
|
||||
services,
|
||||
'serviceId',
|
||||
);
|
||||
(testTarget as any).local.apps['1234'].source = source;
|
||||
await deviceState.setTarget(testTarget2);
|
||||
const target = await deviceState.getTarget();
|
||||
expect(JSON.parse(JSON.stringify(target))).to.deep.equal(
|
||||
JSON.parse(JSON.stringify(testTarget)),
|
||||
);
|
||||
expect(targetState)
|
||||
.to.have.property('local')
|
||||
.that.has.property('apps')
|
||||
.that.has.property('1234')
|
||||
.that.is.an('object');
|
||||
|
||||
const app = targetState.local.apps[1234];
|
||||
expect(app).to.have.property('appName').that.equals('superapp');
|
||||
expect(app).to.have.property('commit').that.equals('afafafa');
|
||||
expect(app).to.have.property('services').that.is.an('array').with.length(2);
|
||||
expect(app.services[0])
|
||||
.to.have.property('config')
|
||||
.that.has.property('image')
|
||||
.that.equals('registry2.resin.io/superapp/edfabc:latest');
|
||||
expect(app.services[0].config)
|
||||
.to.have.property('environment')
|
||||
.that.has.property('FOO')
|
||||
.that.equals('bar');
|
||||
expect(app.services[1])
|
||||
.to.have.property('config')
|
||||
.that.has.property('image')
|
||||
.that.equals('registry2.resin.io/superapp/afaff:latest');
|
||||
expect(app.services[1].config)
|
||||
.to.have.property('environment')
|
||||
.that.has.property('FOO')
|
||||
.that.equals('bro');
|
||||
});
|
||||
|
||||
it('does not allow setting an invalid target state', () => {
|
||||
expect(deviceState.setTarget(testTargetInvalid as any)).to.be.rejected;
|
||||
// v2 state should be rejected
|
||||
expect(
|
||||
deviceState.setTarget({
|
||||
local: {
|
||||
name: 'aDeviceWithDifferentName',
|
||||
config: {
|
||||
RESIN_HOST_CONFIG_gpu_mem: '512',
|
||||
},
|
||||
apps: {
|
||||
1234: {
|
||||
appId: '1234',
|
||||
name: 'superapp',
|
||||
commit: 'afafafa',
|
||||
releaseId: '2',
|
||||
config: {},
|
||||
services: {
|
||||
23: {
|
||||
serviceId: '23',
|
||||
serviceName: 'aservice',
|
||||
imageId: '12345',
|
||||
image: 'registry2.resin.io/superapp/edfabc',
|
||||
environment: {
|
||||
' FOO': 'bar',
|
||||
},
|
||||
labels: {},
|
||||
},
|
||||
24: {
|
||||
serviceId: '24',
|
||||
serviceName: 'anotherService',
|
||||
imageId: '12346',
|
||||
image: 'registry2.resin.io/superapp/afaff',
|
||||
environment: {
|
||||
FOO: 'bro',
|
||||
},
|
||||
labels: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dependent: { apps: {}, devices: {} },
|
||||
} as any),
|
||||
).to.be.rejected;
|
||||
});
|
||||
|
||||
it('allows triggering applying the target state', (done) => {
|
||||
const applyTargetStub = stub(deviceState, 'applyTarget').returns(
|
||||
Promise.resolve(),
|
||||
);
|
||||
const applyTargetStub = sinon
|
||||
.stub(deviceState, 'applyTarget')
|
||||
.returns(Promise.resolve());
|
||||
|
||||
deviceState.triggerApplyTarget({ force: true });
|
||||
expect(applyTargetStub).to.not.be.called;
|
||||
@ -361,6 +360,178 @@ describe('deviceState', () => {
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
it('accepts a target state with an valid contract', async () => {
|
||||
await deviceState.setTarget({
|
||||
local: {
|
||||
name: 'aDeviceWithDifferentName',
|
||||
config: {},
|
||||
apps: {
|
||||
myapp: {
|
||||
id: 1234,
|
||||
name: 'superapp',
|
||||
class: 'fleet',
|
||||
releases: {
|
||||
one: {
|
||||
id: 2,
|
||||
services: {
|
||||
valid: {
|
||||
id: 23,
|
||||
image_id: 12345,
|
||||
image: 'registry2.resin.io/superapp/valid',
|
||||
environment: {},
|
||||
labels: {},
|
||||
},
|
||||
alsoValid: {
|
||||
id: 24,
|
||||
image_id: 12346,
|
||||
image: 'registry2.resin.io/superapp/afaff',
|
||||
contract: {
|
||||
type: 'sw.container',
|
||||
slug: 'supervisor-version',
|
||||
name: 'Enforce supervisor version',
|
||||
requires: [
|
||||
{
|
||||
type: 'sw.supervisor',
|
||||
version: '>=11.0.0',
|
||||
},
|
||||
],
|
||||
},
|
||||
environment: {},
|
||||
labels: {},
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as TargetState);
|
||||
const targetState = await deviceState.getTarget();
|
||||
const app = targetState.local.apps[1234];
|
||||
expect(app).to.have.property('appName').that.equals('superapp');
|
||||
expect(app).to.have.property('commit').that.equals('one');
|
||||
// Only a single service should be on the target state
|
||||
expect(app).to.have.property('services').that.is.an('array').with.length(2);
|
||||
expect(app.services[1])
|
||||
.that.has.property('serviceName')
|
||||
.that.equals('alsoValid');
|
||||
});
|
||||
|
||||
it('accepts a target state with an invalid contract for an optional container', async () => {
|
||||
await deviceState.setTarget({
|
||||
local: {
|
||||
name: 'aDeviceWithDifferentName',
|
||||
config: {},
|
||||
apps: {
|
||||
myapp: {
|
||||
id: 1234,
|
||||
name: 'superapp',
|
||||
class: 'fleet',
|
||||
releases: {
|
||||
one: {
|
||||
id: 2,
|
||||
services: {
|
||||
valid: {
|
||||
id: 23,
|
||||
image_id: 12345,
|
||||
image: 'registry2.resin.io/superapp/valid',
|
||||
environment: {},
|
||||
labels: {},
|
||||
},
|
||||
invalidButOptional: {
|
||||
id: 24,
|
||||
image_id: 12346,
|
||||
image: 'registry2.resin.io/superapp/afaff',
|
||||
contract: {
|
||||
type: 'sw.container',
|
||||
slug: 'supervisor-version',
|
||||
name: 'Enforce supervisor version',
|
||||
requires: [
|
||||
{
|
||||
type: 'sw.supervisor',
|
||||
version: '>=12.0.0',
|
||||
},
|
||||
],
|
||||
},
|
||||
environment: {},
|
||||
labels: {
|
||||
'io.balena.features.optional': 'true',
|
||||
},
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as TargetState);
|
||||
const targetState = await deviceState.getTarget();
|
||||
const app = targetState.local.apps[1234];
|
||||
expect(app).to.have.property('appName').that.equals('superapp');
|
||||
expect(app).to.have.property('commit').that.equals('one');
|
||||
// Only a single service should be on the target state
|
||||
expect(app).to.have.property('services').that.is.an('array').with.length(1);
|
||||
expect(app.services[0])
|
||||
.that.has.property('serviceName')
|
||||
.that.equals('valid');
|
||||
});
|
||||
|
||||
it('rejects a target state with invalid contract and non optional service', async () => {
|
||||
await expect(
|
||||
deviceState.setTarget({
|
||||
local: {
|
||||
name: 'aDeviceWithDifferentName',
|
||||
config: {},
|
||||
apps: {
|
||||
myapp: {
|
||||
id: 1234,
|
||||
name: 'superapp',
|
||||
class: 'fleet',
|
||||
releases: {
|
||||
one: {
|
||||
id: 2,
|
||||
services: {
|
||||
valid: {
|
||||
id: 23,
|
||||
image_id: 12345,
|
||||
image: 'registry2.resin.io/superapp/valid',
|
||||
environment: {},
|
||||
labels: {},
|
||||
},
|
||||
invalid: {
|
||||
id: 24,
|
||||
image_id: 12346,
|
||||
image: 'registry2.resin.io/superapp/afaff',
|
||||
contract: {
|
||||
type: 'sw.container',
|
||||
slug: 'supervisor-version',
|
||||
name: 'Enforce supervisor version',
|
||||
requires: [
|
||||
{
|
||||
type: 'sw.supervisor',
|
||||
version: '>=12.0.0',
|
||||
},
|
||||
],
|
||||
},
|
||||
environment: {},
|
||||
labels: {},
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as TargetState),
|
||||
).to.be.rejected;
|
||||
});
|
||||
|
||||
// TODO: There is no easy way to test this behaviour with the current
|
||||
// interface of device-state. We should really think about the device-state
|
||||
// interface to allow this flexibility (and to avoid having to change module
|
||||
@ -373,9 +544,9 @@ describe('deviceState', () => {
|
||||
|
||||
it('prevents reboot or shutdown when HUP rollback breadcrumbs are present', async () => {
|
||||
const testErrMsg = 'Waiting for Host OS updates to finish';
|
||||
stub(updateLock, 'abortIfHUPInProgress').throws(
|
||||
new UpdatesLockedError(testErrMsg),
|
||||
);
|
||||
sinon
|
||||
.stub(updateLock, 'abortIfHUPInProgress')
|
||||
.throws(new UpdatesLockedError(testErrMsg));
|
||||
|
||||
await expect(deviceState.reboot())
|
||||
.to.eventually.be.rejectedWith(testErrMsg)
|
||||
@ -384,6 +555,6 @@ describe('deviceState', () => {
|
||||
.to.eventually.be.rejectedWith(testErrMsg)
|
||||
.and.be.an.instanceOf(UpdatesLockedError);
|
||||
|
||||
(updateLock.abortIfHUPInProgress as SinonStub).restore();
|
||||
(updateLock.abortIfHUPInProgress as sinon.SinonStub).restore();
|
||||
});
|
||||
});
|
||||
|
@ -258,9 +258,11 @@ describe('validation', () => {
|
||||
expect(
|
||||
isRight(
|
||||
TargetApps.decode({
|
||||
'1234': {
|
||||
abcd: {
|
||||
id: 1234,
|
||||
name: 'something',
|
||||
services: {},
|
||||
class: 'fleet',
|
||||
releases: {},
|
||||
},
|
||||
}),
|
||||
),
|
||||
@ -269,11 +271,15 @@ describe('validation', () => {
|
||||
expect(
|
||||
isRight(
|
||||
TargetApps.decode({
|
||||
'1234': {
|
||||
abcd: {
|
||||
id: 1234,
|
||||
name: 'something',
|
||||
releaseId: 123,
|
||||
commit: 'bar',
|
||||
services: {},
|
||||
releases: {
|
||||
bar: {
|
||||
id: 123,
|
||||
services: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
@ -283,21 +289,25 @@ describe('validation', () => {
|
||||
expect(
|
||||
isRight(
|
||||
TargetApps.decode({
|
||||
'1234': {
|
||||
abcd: {
|
||||
id: 1234,
|
||||
name: 'something',
|
||||
releaseId: 123,
|
||||
commit: 'bar',
|
||||
services: {
|
||||
'45': {
|
||||
serviceName: 'bazbaz',
|
||||
imageId: 34,
|
||||
image: 'foo',
|
||||
environment: { MY_SERVICE_ENV_VAR: '123' },
|
||||
labels: { 'io.balena.features.supervisor-api': 'true' },
|
||||
releases: {
|
||||
bar: {
|
||||
id: 123,
|
||||
services: {
|
||||
bazbaz: {
|
||||
id: 45,
|
||||
image_id: 34,
|
||||
image: 'foo',
|
||||
environment: { MY_SERVICE_ENV_VAR: '123' },
|
||||
labels: { 'io.balena.features.supervisor-api': 'true' },
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
}),
|
||||
),
|
||||
@ -309,17 +319,23 @@ describe('validation', () => {
|
||||
expect(
|
||||
isRight(
|
||||
TargetApps.decode({
|
||||
'1234': {
|
||||
abcd: {
|
||||
id: 1234,
|
||||
name: 'something',
|
||||
releaseId: 123,
|
||||
commit: 'bar',
|
||||
services: {
|
||||
'45': {
|
||||
serviceName: 'bazbaz',
|
||||
imageId: 34,
|
||||
image: 'foo',
|
||||
environment: { ' baz': 'bat' },
|
||||
labels: {},
|
||||
releases: {
|
||||
bar: {
|
||||
id: 123,
|
||||
services: {
|
||||
bazbaz: {
|
||||
id: 45,
|
||||
image_id: 34,
|
||||
image: 'foo',
|
||||
environment: { ' aaa': '123' },
|
||||
labels: {},
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -332,17 +348,23 @@ describe('validation', () => {
|
||||
expect(
|
||||
isRight(
|
||||
TargetApps.decode({
|
||||
'1234': {
|
||||
abcd: {
|
||||
id: 1234,
|
||||
name: 'something',
|
||||
releaseId: 123,
|
||||
commit: 'bar',
|
||||
services: {
|
||||
'45': {
|
||||
serviceName: 'bazbaz',
|
||||
imageId: 34,
|
||||
image: 'foo',
|
||||
environment: {},
|
||||
labels: { ' not a valid #name': 'label value' },
|
||||
releases: {
|
||||
bar: {
|
||||
id: 123,
|
||||
services: {
|
||||
bazbaz: {
|
||||
id: 45,
|
||||
image_id: 34,
|
||||
image: 'foo',
|
||||
environment: {},
|
||||
labels: { ' not a valid #name': 'label value' },
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -355,40 +377,35 @@ describe('validation', () => {
|
||||
expect(
|
||||
isRight(
|
||||
TargetApps.decode({
|
||||
boo: {
|
||||
abcd: {
|
||||
id: 'booo',
|
||||
name: 'something',
|
||||
releaseId: 123,
|
||||
commit: 'bar',
|
||||
services: {
|
||||
'45': {
|
||||
serviceName: 'bazbaz',
|
||||
imageId: 34,
|
||||
image: 'foo',
|
||||
environment: {},
|
||||
labels: {},
|
||||
},
|
||||
},
|
||||
releases: {},
|
||||
},
|
||||
}),
|
||||
),
|
||||
).to.be.false;
|
||||
});
|
||||
|
||||
it('rejects a commit that is too long', () => {
|
||||
it('rejects a release uuid that is too long', () => {
|
||||
expect(
|
||||
isRight(
|
||||
TargetApps.decode({
|
||||
boo: {
|
||||
abcd: {
|
||||
id: '123',
|
||||
name: 'something',
|
||||
releaseId: 123,
|
||||
commit: 'a'.repeat(256),
|
||||
services: {
|
||||
'45': {
|
||||
serviceName: 'bazbaz',
|
||||
imageId: 34,
|
||||
image: 'foo',
|
||||
environment: {},
|
||||
labels: {},
|
||||
releases: {
|
||||
['a'.repeat(256)]: {
|
||||
id: 1234,
|
||||
services: {
|
||||
bazbaz: {
|
||||
id: 45,
|
||||
image_id: 34,
|
||||
image: 'foo',
|
||||
environment: {},
|
||||
labels: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -400,17 +417,21 @@ describe('validation', () => {
|
||||
expect(
|
||||
isRight(
|
||||
TargetApps.decode({
|
||||
boo: {
|
||||
abcd: {
|
||||
id: '123',
|
||||
name: 'something',
|
||||
releaseId: 123,
|
||||
commit: 'a'.repeat(256),
|
||||
services: {
|
||||
'45': {
|
||||
serviceName: ' not a valid name',
|
||||
imageId: 34,
|
||||
image: 'foo',
|
||||
environment: {},
|
||||
labels: {},
|
||||
releases: {
|
||||
aaaa: {
|
||||
id: 1234,
|
||||
services: {
|
||||
' not a valid name': {
|
||||
id: 45,
|
||||
image_id: 34,
|
||||
image: 'foo',
|
||||
environment: {},
|
||||
labels: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -420,44 +441,17 @@ describe('validation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a commit that is too long', () => {
|
||||
expect(
|
||||
isRight(
|
||||
TargetApps.decode({
|
||||
boo: {
|
||||
name: 'something',
|
||||
releaseId: 123,
|
||||
commit: 'a'.repeat(256),
|
||||
services: {
|
||||
'45': {
|
||||
serviceName: 'bazbaz',
|
||||
imageId: 34,
|
||||
image: 'foo',
|
||||
environment: {},
|
||||
labels: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
).to.be.false;
|
||||
});
|
||||
|
||||
it('rejects app with an invalid releaseId', () => {
|
||||
expect(
|
||||
isRight(
|
||||
TargetApps.decode({
|
||||
boo: {
|
||||
abcd: {
|
||||
id: '123',
|
||||
name: 'something',
|
||||
releaseId: '123aaa',
|
||||
commit: 'bar',
|
||||
services: {
|
||||
'45': {
|
||||
serviceName: 'bazbaz',
|
||||
imageId: 34,
|
||||
image: 'foo',
|
||||
environment: {},
|
||||
labels: {},
|
||||
releases: {
|
||||
aaaa: {
|
||||
id: 'boooo',
|
||||
services: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -405,7 +405,7 @@ describe('ApiBinder', () => {
|
||||
});
|
||||
|
||||
it('fails when stateReportHealthy is false', async () => {
|
||||
const currentState = await import('../src/device-state/current-state');
|
||||
const currentState = await import('../src/api-binder/report');
|
||||
|
||||
configStub.resolves({
|
||||
unmanaged: false,
|
||||
|
@ -10,7 +10,7 @@ import LocalModeManager, {
|
||||
} from '../src/local-mode';
|
||||
import ShortStackError from './lib/errors';
|
||||
|
||||
describe.skip('LocalModeManager', () => {
|
||||
describe('LocalModeManager', () => {
|
||||
let localMode: LocalModeManager;
|
||||
let dockerStub: sinon.SinonStubbedInstance<typeof docker>;
|
||||
|
||||
|
@ -1,166 +1,386 @@
|
||||
import { expect } from 'chai';
|
||||
import * as _ from 'lodash';
|
||||
|
||||
import prepare = require('./lib/prepare');
|
||||
import { isRight } from 'fp-ts/lib/Either';
|
||||
import * as sinon from 'sinon';
|
||||
import App from '../src/compose/app';
|
||||
import Network from '../src/compose/network';
|
||||
import * as config from '../src/config';
|
||||
import * as dbFormat from '../src/device-state/db-format';
|
||||
import * as targetStateCache from '../src/device-state/target-state-cache';
|
||||
import * as images from '../src/compose/images';
|
||||
import log from '../src/lib/supervisor-console';
|
||||
import { TargetApps } from '../src/types/state';
|
||||
import * as dbHelper from './lib/db-helper';
|
||||
|
||||
import App from '../src/compose/app';
|
||||
import Service from '../src/compose/service';
|
||||
import Network from '../src/compose/network';
|
||||
import { TargetApp } from '../src/types/state';
|
||||
|
||||
function getDefaultNetworks(appId: number) {
|
||||
function getDefaultNetwork(appId: number) {
|
||||
return {
|
||||
default: Network.fromComposeObject('default', appId, {}),
|
||||
default: Network.fromComposeObject('default', appId, 'deadbeef', {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('DB Format', () => {
|
||||
const originalInspect = images.inspectByName;
|
||||
describe('db-format', () => {
|
||||
let testDb: dbHelper.TestDatabase;
|
||||
let apiEndpoint: string;
|
||||
before(async () => {
|
||||
await prepare();
|
||||
await config.initialized;
|
||||
await targetStateCache.initialized;
|
||||
testDb = await dbHelper.createDB();
|
||||
|
||||
await config.initialized;
|
||||
// Prevent side effects from changes in config
|
||||
sinon.stub(config, 'on');
|
||||
|
||||
// TargetStateCache checks the API endpoint to
|
||||
// store and invalidate the cache
|
||||
// TODO: this is an implementation detail that
|
||||
// should not be part of the test suite. We need to change
|
||||
// the target state architecture for this
|
||||
apiEndpoint = await config.get('apiEndpoint');
|
||||
|
||||
// Setup some mocks
|
||||
// @ts-expect-error Assigning to a RO property
|
||||
images.inspectByName = () => {
|
||||
const error = new Error();
|
||||
// @ts-ignore
|
||||
error.statusCode = 404;
|
||||
return Promise.reject(error);
|
||||
};
|
||||
await targetStateCache.setTargetApps([
|
||||
{
|
||||
appId: 1,
|
||||
commit: 'abcdef',
|
||||
name: 'test-app',
|
||||
source: apiEndpoint,
|
||||
releaseId: 123,
|
||||
services: '[]',
|
||||
networks: '[]',
|
||||
volumes: '[]',
|
||||
},
|
||||
{
|
||||
appId: 2,
|
||||
commit: 'abcdef2',
|
||||
name: 'test-app2',
|
||||
source: apiEndpoint,
|
||||
releaseId: 1232,
|
||||
services: JSON.stringify([
|
||||
{
|
||||
serviceName: 'test-service',
|
||||
image: 'test-image',
|
||||
imageId: 5,
|
||||
environment: {
|
||||
TEST_VAR: 'test-string',
|
||||
},
|
||||
tty: true,
|
||||
appId: 2,
|
||||
releaseId: 1232,
|
||||
serviceId: 567,
|
||||
commit: 'abcdef2',
|
||||
},
|
||||
]),
|
||||
networks: '[]',
|
||||
volumes: '[]',
|
||||
},
|
||||
]);
|
||||
// disable log output during testing
|
||||
sinon.stub(log, 'debug');
|
||||
sinon.stub(log, 'warn');
|
||||
sinon.stub(log, 'info');
|
||||
sinon.stub(log, 'event');
|
||||
sinon.stub(log, 'success');
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await prepare();
|
||||
try {
|
||||
await testDb.destroy();
|
||||
} catch (e) {
|
||||
/* noop */
|
||||
}
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
// @ts-expect-error Assigning to a RO property
|
||||
images.inspectByName = originalInspect;
|
||||
afterEach(async () => {
|
||||
await testDb.reset();
|
||||
});
|
||||
|
||||
it('converts target apps into the database format', async () => {
|
||||
await dbFormat.setApps(
|
||||
{
|
||||
deadbeef: {
|
||||
id: 1,
|
||||
name: 'test-app',
|
||||
class: 'fleet',
|
||||
releases: {
|
||||
one: {
|
||||
id: 1,
|
||||
services: {
|
||||
ubuntu: {
|
||||
id: 1,
|
||||
image_id: 1,
|
||||
image: 'ubuntu:latest',
|
||||
environment: {},
|
||||
labels: { 'my-label': 'true' },
|
||||
composition: {
|
||||
command: ['sleep', 'infinity'],
|
||||
},
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'local',
|
||||
);
|
||||
|
||||
const [app] = await testDb.models('app').where({ uuid: 'deadbeef' });
|
||||
expect(app).to.not.be.undefined;
|
||||
expect(app.name).to.equal('test-app');
|
||||
expect(app.releaseId).to.equal(1);
|
||||
expect(app.commit).to.equal('one');
|
||||
expect(app.appId).to.equal(1);
|
||||
expect(app.source).to.equal('local');
|
||||
expect(app.uuid).to.equal('deadbeef');
|
||||
expect(app.isHost).to.equal(0);
|
||||
expect(app.services).to.equal(
|
||||
'[{"image":"ubuntu:latest","environment":{},"labels":{"my-label":"true"},"composition":{"command":["sleep","infinity"]},"appId":1,"appUuid":"deadbeef","releaseId":1,"commit":"one","imageId":1,"serviceId":1,"serviceName":"ubuntu"}]',
|
||||
);
|
||||
expect(app.volumes).to.equal('{}');
|
||||
expect(app.networks).to.equal('{}');
|
||||
});
|
||||
|
||||
it('should retrieve a single app from the database', async () => {
|
||||
await dbFormat.setApps(
|
||||
{
|
||||
deadbeef: {
|
||||
id: 1,
|
||||
name: 'test-app',
|
||||
class: 'fleet',
|
||||
releases: {
|
||||
one: {
|
||||
id: 1,
|
||||
services: {
|
||||
ubuntu: {
|
||||
id: 1,
|
||||
image_id: 1,
|
||||
image: 'ubuntu:latest',
|
||||
environment: {},
|
||||
labels: { 'my-label': 'true' },
|
||||
composition: {
|
||||
command: ['sleep', 'infinity'],
|
||||
},
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
apiEndpoint,
|
||||
);
|
||||
|
||||
const app = await dbFormat.getApp(1);
|
||||
expect(app).to.be.an.instanceOf(App);
|
||||
expect(app).to.have.property('appId').that.equals(1);
|
||||
expect(app).to.have.property('commit').that.equals('abcdef');
|
||||
expect(app).to.have.property('commit').that.equals('one');
|
||||
expect(app).to.have.property('appName').that.equals('test-app');
|
||||
expect(app)
|
||||
.to.have.property('source')
|
||||
.that.deep.equals(await config.get('apiEndpoint'));
|
||||
expect(app).to.have.property('services').that.deep.equals([]);
|
||||
expect(app).to.have.property('source').that.equals(apiEndpoint);
|
||||
expect(app).to.have.property('services').that.has.lengthOf(1);
|
||||
expect(app).to.have.property('volumes').that.deep.equals({});
|
||||
expect(app)
|
||||
.to.have.property('networks')
|
||||
.that.deep.equals(getDefaultNetworks(1));
|
||||
});
|
||||
.that.deep.equals(getDefaultNetwork(1));
|
||||
|
||||
it('should correctly build services from the database', async () => {
|
||||
const app = await dbFormat.getApp(2);
|
||||
expect(app).to.have.property('services').that.is.an('array');
|
||||
const services = _.keyBy(app.services, 'serviceId');
|
||||
expect(Object.keys(services)).to.deep.equal(['567']);
|
||||
|
||||
const service = services[567];
|
||||
expect(service).to.be.instanceof(Service);
|
||||
// Don't do a deep equals here as a bunch of other properties are added that are
|
||||
// tested elsewhere
|
||||
const [service] = app.services;
|
||||
expect(service).to.have.property('appId').that.equals(1);
|
||||
expect(service).to.have.property('serviceId').that.equals(1);
|
||||
expect(service).to.have.property('imageId').that.equals(1);
|
||||
expect(service).to.have.property('releaseId').that.equals(1);
|
||||
expect(service.config)
|
||||
.to.have.property('environment')
|
||||
.that.has.property('TEST_VAR')
|
||||
.that.equals('test-string');
|
||||
expect(service.config).to.have.property('tty').that.equals(true);
|
||||
expect(service).to.have.property('imageName').that.equals('test-image');
|
||||
expect(service).to.have.property('imageId').that.equals(5);
|
||||
.to.have.property('image')
|
||||
.that.equals('ubuntu:latest');
|
||||
expect(service.config)
|
||||
.to.have.property('labels')
|
||||
.that.deep.includes({ 'my-label': 'true' });
|
||||
expect(service.config)
|
||||
.to.have.property('command')
|
||||
.that.deep.equals(['sleep', 'infinity']);
|
||||
});
|
||||
|
||||
it('should retrieve multiple apps from the database', async () => {
|
||||
const apps = await dbFormat.getApps();
|
||||
expect(Object.keys(apps)).to.have.length(2).and.deep.equal(['1', '2']);
|
||||
await dbFormat.setApps(
|
||||
{
|
||||
deadbeef: {
|
||||
id: 1,
|
||||
name: 'test-app',
|
||||
class: 'fleet',
|
||||
releases: {
|
||||
one: {
|
||||
id: 1,
|
||||
services: {
|
||||
ubuntu: {
|
||||
id: 1,
|
||||
image_id: 1,
|
||||
image: 'ubuntu:latest',
|
||||
environment: {},
|
||||
labels: {},
|
||||
composition: {
|
||||
command: ['sleep', 'infinity'],
|
||||
},
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
deadc0de: {
|
||||
id: 2,
|
||||
name: 'other-app',
|
||||
class: 'app',
|
||||
releases: {
|
||||
two: {
|
||||
id: 2,
|
||||
services: {},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
apiEndpoint,
|
||||
);
|
||||
|
||||
const apps = Object.values(await dbFormat.getApps());
|
||||
expect(apps).to.have.lengthOf(2);
|
||||
|
||||
const [app, otherapp] = apps;
|
||||
expect(app).to.be.an.instanceOf(App);
|
||||
expect(app).to.have.property('appId').that.equals(1);
|
||||
expect(app).to.have.property('commit').that.equals('one');
|
||||
expect(app).to.have.property('appName').that.equals('test-app');
|
||||
expect(app).to.have.property('source').that.equals(apiEndpoint);
|
||||
expect(app).to.have.property('services').that.has.lengthOf(1);
|
||||
expect(app).to.have.property('volumes').that.deep.equals({});
|
||||
expect(app)
|
||||
.to.have.property('networks')
|
||||
.that.deep.equals(getDefaultNetwork(1));
|
||||
|
||||
expect(otherapp).to.have.property('appId').that.equals(2);
|
||||
expect(otherapp).to.have.property('commit').that.equals('two');
|
||||
expect(otherapp).to.have.property('appName').that.equals('other-app');
|
||||
});
|
||||
|
||||
it('should write target states to the database', async () => {
|
||||
const target = await import('./data/state-endpoints/simple.json');
|
||||
const dbApps: { [appId: number]: TargetApp } = {};
|
||||
dbApps[1234] = {
|
||||
...target.local.apps[1234],
|
||||
it('should retrieve non-fleet apps from the database if local mode is set', async () => {
|
||||
await dbFormat.setApps(
|
||||
{
|
||||
deadbeef: {
|
||||
id: 1,
|
||||
name: 'test-app',
|
||||
class: 'fleet',
|
||||
releases: {
|
||||
one: {
|
||||
id: 1,
|
||||
services: {
|
||||
ubuntu: {
|
||||
id: 1,
|
||||
image_id: 1,
|
||||
image: 'ubuntu:latest',
|
||||
environment: {},
|
||||
labels: {},
|
||||
composition: {
|
||||
command: ['sleep', 'infinity'],
|
||||
},
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
deadc0de: {
|
||||
id: 2,
|
||||
name: 'other-app',
|
||||
class: 'app',
|
||||
releases: {
|
||||
two: {
|
||||
id: 2,
|
||||
services: {},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
apiEndpoint,
|
||||
);
|
||||
|
||||
// Once local mode is set to true, only 'other-app' should be returned
|
||||
// as part of the target
|
||||
await config.set({ localMode: true });
|
||||
|
||||
const apps = Object.values(await dbFormat.getApps());
|
||||
expect(apps).to.have.lengthOf(1);
|
||||
|
||||
const [app] = apps;
|
||||
expect(app).to.be.an.instanceOf(App);
|
||||
expect(app).to.have.property('appId').that.equals(2);
|
||||
expect(app).to.have.property('commit').that.equals('two');
|
||||
expect(app).to.have.property('appName').that.equals('other-app');
|
||||
|
||||
// Set the app as local now
|
||||
await dbFormat.setApps(
|
||||
{
|
||||
deadbeef: {
|
||||
id: 1,
|
||||
name: 'test-app',
|
||||
class: 'fleet',
|
||||
releases: {
|
||||
one: {
|
||||
id: 1,
|
||||
services: {
|
||||
ubuntu: {
|
||||
id: 1,
|
||||
image_id: 1,
|
||||
image: 'ubuntu:latest',
|
||||
environment: {},
|
||||
labels: {},
|
||||
composition: {
|
||||
command: ['sleep', 'infinity'],
|
||||
},
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'local',
|
||||
);
|
||||
|
||||
// Now both apps should be returned
|
||||
const newapps = Object.values(await dbFormat.getApps());
|
||||
expect(newapps).to.have.lengthOf(2);
|
||||
|
||||
const [newapp, otherapp] = newapps;
|
||||
expect(newapp).to.be.an.instanceOf(App);
|
||||
expect(newapp).to.have.property('appId').that.equals(1);
|
||||
expect(newapp).to.have.property('commit').that.equals('one');
|
||||
expect(newapp).to.have.property('appName').that.equals('test-app');
|
||||
expect(newapp).to.have.property('source').that.equals('local');
|
||||
expect(newapp).to.have.property('services').that.has.lengthOf(1);
|
||||
expect(newapp).to.have.property('volumes').that.deep.equals({});
|
||||
expect(newapp)
|
||||
.to.have.property('networks')
|
||||
.that.deep.equals(getDefaultNetwork(1));
|
||||
|
||||
expect(otherapp).to.have.property('appId').that.equals(2);
|
||||
expect(otherapp).to.have.property('commit').that.equals('two');
|
||||
expect(otherapp).to.have.property('appName').that.equals('other-app');
|
||||
});
|
||||
|
||||
it('should retrieve app target state from database', async () => {
|
||||
const srcApps: TargetApps = {
|
||||
deadbeef: {
|
||||
id: 1,
|
||||
name: 'test-app',
|
||||
class: 'fleet',
|
||||
is_host: false,
|
||||
releases: {
|
||||
one: {
|
||||
id: 1,
|
||||
services: {
|
||||
ubuntu: {
|
||||
id: 1,
|
||||
image_id: 1,
|
||||
image: 'ubuntu:latest',
|
||||
environment: {},
|
||||
labels: { 'my-label': 'true' },
|
||||
composition: {
|
||||
command: ['sleep', 'infinity'],
|
||||
},
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
deadc0de: {
|
||||
id: 2,
|
||||
name: 'other-app',
|
||||
class: 'app',
|
||||
is_host: false,
|
||||
releases: {
|
||||
two: {
|
||||
id: 2,
|
||||
services: {},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await dbFormat.setApps(dbApps, apiEndpoint);
|
||||
|
||||
const app = await dbFormat.getApp(1234);
|
||||
|
||||
expect(app).to.have.property('appName').that.equals('pi4test');
|
||||
expect(app).to.have.property('services').that.is.an('array');
|
||||
expect(_.keyBy(app.services, 'serviceId'))
|
||||
.to.have.property('482141')
|
||||
.that.has.property('serviceName')
|
||||
.that.equals('main');
|
||||
});
|
||||
|
||||
it('should add default and missing fields when retreiving from the database', async () => {
|
||||
const originalImagesInspect = images.inspectByName;
|
||||
try {
|
||||
// @ts-expect-error Assigning a RO property
|
||||
images.inspectByName = () =>
|
||||
Promise.resolve({
|
||||
Config: { Cmd: ['someCommand'], Entrypoint: ['theEntrypoint'] },
|
||||
});
|
||||
|
||||
const app = await dbFormat.getApp(2);
|
||||
const conf =
|
||||
app.services[parseInt(Object.keys(app.services)[0], 10)].config;
|
||||
expect(conf)
|
||||
.to.have.property('entrypoint')
|
||||
.that.deep.equals(['theEntrypoint']);
|
||||
expect(conf)
|
||||
.to.have.property('command')
|
||||
.that.deep.equals(['someCommand']);
|
||||
} finally {
|
||||
// @ts-expect-error Assigning a RO property
|
||||
images.inspectByName = originalImagesInspect;
|
||||
}
|
||||
await dbFormat.setApps(srcApps, apiEndpoint);
|
||||
const result = await dbFormat.getTargetJson();
|
||||
expect(
|
||||
isRight(TargetApps.decode(result)),
|
||||
'resulting target apps is a valid TargetApps object',
|
||||
);
|
||||
expect(result).to.deep.equal(srcApps);
|
||||
});
|
||||
});
|
||||
|
@ -161,10 +161,13 @@ describe('Host Firewall', function () {
|
||||
await targetStateCache.setTargetApps([
|
||||
{
|
||||
appId: 2,
|
||||
uuid: 'myapp',
|
||||
commit: 'abcdef2',
|
||||
name: 'test-app2',
|
||||
class: 'fleet',
|
||||
source: apiEndpoint,
|
||||
releaseId: 1232,
|
||||
isHost: false,
|
||||
services: JSON.stringify([
|
||||
{
|
||||
serviceName: 'test-service',
|
||||
@ -213,24 +216,29 @@ describe('Host Firewall', function () {
|
||||
await targetStateCache.setTargetApps([
|
||||
{
|
||||
appId: 2,
|
||||
uuid: 'myapp',
|
||||
commit: 'abcdef2',
|
||||
name: 'test-app2',
|
||||
source: apiEndpoint,
|
||||
class: 'fleet',
|
||||
releaseId: 1232,
|
||||
isHost: false,
|
||||
services: JSON.stringify([
|
||||
{
|
||||
serviceName: 'test-service',
|
||||
networkMode: 'host',
|
||||
image: 'test-image',
|
||||
imageId: 5,
|
||||
environment: {
|
||||
TEST_VAR: 'test-string',
|
||||
},
|
||||
tty: true,
|
||||
appId: 2,
|
||||
releaseId: 1232,
|
||||
serviceId: 567,
|
||||
commit: 'abcdef2',
|
||||
composition: {
|
||||
tty: true,
|
||||
network_mode: 'host',
|
||||
},
|
||||
},
|
||||
]),
|
||||
networks: '[]',
|
||||
|
@ -44,9 +44,9 @@ describe('SupervisorAPI [V1 Endpoints]', () => {
|
||||
`http://127.0.0.1:${mockedAPI.mockedOptions.listenPort}`,
|
||||
);
|
||||
const services = [
|
||||
{ appId: 2, serviceId: 640681, serviceName: 'one' },
|
||||
{ appId: 2, serviceId: 640682, serviceName: 'two' },
|
||||
{ appId: 2, serviceId: 640683, serviceName: 'three' },
|
||||
{ appId: 2, appUuid: 'deadbeef', serviceId: 640681, serviceName: 'one' },
|
||||
{ appId: 2, appUuid: 'deadbeef', serviceId: 640682, serviceName: 'two' },
|
||||
{ appId: 2, appUuid: 'deadbeef', serviceId: 640683, serviceName: 'three' },
|
||||
];
|
||||
const containers = services.map((service) => mockedAPI.mockService(service));
|
||||
const images = services.map((service) => mockedAPI.mockImage(service));
|
||||
@ -61,6 +61,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => {
|
||||
|
||||
targetStateCacheMock.resolves({
|
||||
appId: 2,
|
||||
appUuid: 'deadbeef',
|
||||
commit: 'abcdef2',
|
||||
name: 'test-app2',
|
||||
source: 'https://api.balena-cloud.com',
|
||||
|
@ -49,7 +49,7 @@ describe('SupervisorAPI [V2 Endpoints]', () => {
|
||||
await apiKeys.initialized;
|
||||
await apiKeys.generateCloudKey();
|
||||
serviceManagerMock = stub(serviceManager, 'getAll').resolves([]);
|
||||
imagesMock = stub(images, 'getStatus').resolves([]);
|
||||
imagesMock = stub(images, 'getState').resolves([]);
|
||||
|
||||
// We want to check the actual step that was triggered
|
||||
applicationManagerSpy = spy(applicationManager, 'executeStep');
|
||||
|
@ -1,26 +1,29 @@
|
||||
{
|
||||
"name": "aDevice",
|
||||
"config": {
|
||||
"RESIN_HOST_CONFIG_gpu_mem": "256",
|
||||
"RESIN_HOST_LOG_TO_DISPLAY": "0"
|
||||
},
|
||||
"apps": {
|
||||
"1234": {
|
||||
"name": "superapp",
|
||||
"commit": "abcdef",
|
||||
"releaseId": 1,
|
||||
"services": {
|
||||
"23": {
|
||||
"imageId": 12345,
|
||||
"serviceName": "someservice",
|
||||
"image": "registry2.resin.io/superapp/abcdef",
|
||||
"labels": {
|
||||
"io.resin.something": "bar"
|
||||
},
|
||||
"environment": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"RESIN_HOST_CONFIG_gpu_mem": "256",
|
||||
"RESIN_HOST_LOG_TO_DISPLAY": "0"
|
||||
},
|
||||
"apps": {
|
||||
"myapp": {
|
||||
"id": 1234,
|
||||
"name": "superapp",
|
||||
"releases": {
|
||||
"abcdef": {
|
||||
"id": 1,
|
||||
"services": {
|
||||
"someservice": {
|
||||
"id": 123,
|
||||
"image_id": 12345,
|
||||
"image": "registry2.resin.io/superapp/abcdef",
|
||||
"labels": {
|
||||
"io.resin.something": "bar"
|
||||
},
|
||||
"environment": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"pinDevice": true
|
||||
}
|
||||
|
@ -1,25 +1,28 @@
|
||||
{
|
||||
"name": "aDevice",
|
||||
"config": {
|
||||
"RESIN_HOST_CONFIG_gpu_mem": "256",
|
||||
"RESIN_HOST_LOG_TO_DISPLAY": "0"
|
||||
},
|
||||
"apps": {
|
||||
"1234": {
|
||||
"name": "superapp",
|
||||
"commit": "abcdef",
|
||||
"releaseId": 1,
|
||||
"services": {
|
||||
"23": {
|
||||
"imageId": 12345,
|
||||
"serviceName": "someservice",
|
||||
"image": "registry2.resin.io/superapp/abcdef",
|
||||
"labels": {
|
||||
"io.resin.something": "bar"
|
||||
},
|
||||
"environment": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"config": {
|
||||
"RESIN_HOST_CONFIG_gpu_mem": "256",
|
||||
"RESIN_HOST_LOG_TO_DISPLAY": "0"
|
||||
},
|
||||
"apps": {
|
||||
"myapp": {
|
||||
"id": 1234,
|
||||
"name": "superapp",
|
||||
"releases": {
|
||||
"abcdef": {
|
||||
"id": 1,
|
||||
"services": {
|
||||
"someservice": {
|
||||
"id": 123,
|
||||
"image_id": 12345,
|
||||
"image": "registry2.resin.io/superapp/abcdef",
|
||||
"labels": {
|
||||
"io.resin.something": "bar"
|
||||
},
|
||||
"environment": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -6,9 +6,12 @@
|
||||
"environment": {},
|
||||
"labels": {},
|
||||
"appId": 1011165,
|
||||
"appUuid": "aaaaaaaa",
|
||||
"releaseId": 597007,
|
||||
"serviceId": 43697,
|
||||
"commit": "ff300a701054ac15281de1f9c0e84b8c",
|
||||
"imageName": "registry2.resin.io/v2/bf9c649a5ac2fe147bbe350875042388@sha256:3a5c17b715b4f8265539c1a006dd1abdd2ff3b758aa23df99f77c792f40c3d43",
|
||||
"tty": true
|
||||
"composition": {
|
||||
"tty": true
|
||||
}
|
||||
}
|
||||
|
@ -42,7 +42,7 @@
|
||||
"Type": "journald",
|
||||
"Config": {}
|
||||
},
|
||||
"NetworkMode": "1011165_default",
|
||||
"NetworkMode": "aaaaaaaa_default",
|
||||
"PortBindings": {},
|
||||
"RestartPolicy": {
|
||||
"Name": "always",
|
||||
@ -142,6 +142,7 @@
|
||||
"StdinOnce": false,
|
||||
"Env": [
|
||||
"RESIN_APP_ID=1011165",
|
||||
"RESIN_APP_UUID=aaaaaaaa",
|
||||
"RESIN_APP_NAME=supervisortest",
|
||||
"RESIN_SERVICE_NAME=main",
|
||||
"RESIN_DEVICE_UUID=a7feb967fac7f559ccf2a006a36bcf5d",
|
||||
@ -152,6 +153,7 @@
|
||||
"RESIN_SERVICE_KILL_ME_PATH=/tmp/balena/handover-complete",
|
||||
"RESIN=1",
|
||||
"BALENA_APP_ID=1011165",
|
||||
"BALENA_APP_UUID=aaaaaaaa",
|
||||
"BALENA_APP_NAME=supervisortest",
|
||||
"BALENA_SERVICE_NAME=main",
|
||||
"BALENA_DEVICE_UUID=a7feb967fac7f559ccf2a006a36bcf5d",
|
||||
@ -181,6 +183,7 @@
|
||||
"OnBuild": null,
|
||||
"Labels": {
|
||||
"io.resin.app-id": "1011165",
|
||||
"io.balena.app-uuid": "aaaaaaaa",
|
||||
"io.resin.service-id": "43697",
|
||||
"io.resin.service-name": "main",
|
||||
"io.resin.supervised": "true"
|
||||
@ -206,7 +209,7 @@
|
||||
"IPv6Gateway": "",
|
||||
"MacAddress": "",
|
||||
"Networks": {
|
||||
"1011165_default": {
|
||||
"aaaaaaaa_default": {
|
||||
"IPAMConfig": {},
|
||||
"Links": null,
|
||||
"Aliases": [
|
||||
|
@ -4,10 +4,13 @@
|
||||
"image": "sha256:f9e0fa6e3e68caedbcbb4ef35d5a8dce2a8d33e39cc94115d567800f25d826f4",
|
||||
"running": true,
|
||||
"appId": 1011165,
|
||||
"appUuid": "aaaaaaaa",
|
||||
"releaseId": 572579,
|
||||
"serviceId": 43697,
|
||||
"commit": "b14730d691467ab0f448a308af6bf839",
|
||||
"imageName": "registry2.resin.io/v2/8ddbe4a22e881f06def0f31400bfb6de@sha256:09b0db9e71cead5f91107fc9254b1af7088444cc6da55afa2da595940f72a34a",
|
||||
"tty": true,
|
||||
"network_mode": "service: test"
|
||||
"composition": {
|
||||
"tty": true,
|
||||
"network_mode": "service: test"
|
||||
}
|
||||
}
|
||||
|
@ -142,6 +142,7 @@
|
||||
"StdinOnce": false,
|
||||
"Env": [
|
||||
"RESIN_APP_ID=1011165",
|
||||
"RESIN_APP_UUID=aaaaaaaa",
|
||||
"RESIN_APP_NAME=supervisortest",
|
||||
"RESIN_SERVICE_NAME=main",
|
||||
"RESIN_DEVICE_UUID=7dadabd4edec3067948d5952c2f2f26f",
|
||||
@ -152,6 +153,7 @@
|
||||
"RESIN_SERVICE_KILL_ME_PATH=/tmp/balena/handover-complete",
|
||||
"RESIN=1",
|
||||
"BALENA_APP_ID=1011165",
|
||||
"BALENA_APP_UUID=aaaaaaaa",
|
||||
"BALENA_APP_NAME=supervisortest",
|
||||
"BALENA_SERVICE_NAME=main",
|
||||
"BALENA_DEVICE_UUID=7dadabd4edec3067948d5952c2f2f26f",
|
||||
@ -179,6 +181,7 @@
|
||||
"OnBuild": null,
|
||||
"Labels": {
|
||||
"io.resin.app-id": "1011165",
|
||||
"io.balena.app-uuid": "aaaaaaaa",
|
||||
"io.resin.architecture": "armv7hf",
|
||||
"io.resin.device-type": "raspberry-pi2",
|
||||
"io.resin.qemu.version": "2.9.0.resin1-arm",
|
||||
@ -206,7 +209,7 @@
|
||||
"IPv6Gateway": "",
|
||||
"MacAddress": "",
|
||||
"Networks": {
|
||||
"1011165_default": {
|
||||
"aaaaaaaa_default": {
|
||||
"IPAMConfig": null,
|
||||
"Links": null,
|
||||
"Aliases": [
|
||||
|
@ -4,9 +4,12 @@
|
||||
"image": "sha256:f9e0fa6e3e68caedbcbb4ef35d5a8dce2a8d33e39cc94115d567800f25d826f4",
|
||||
"running": true,
|
||||
"appId": 1011165,
|
||||
"appUuid": "aaaaaaaa",
|
||||
"releaseId": 572579,
|
||||
"serviceId": 43697,
|
||||
"commit": "b14730d691467ab0f448a308af6bf839",
|
||||
"imageName": "registry2.resin.io/v2/8ddbe4a22e881f06def0f31400bfb6de@sha256:09b0db9e71cead5f91107fc9254b1af7088444cc6da55afa2da595940f72a34a",
|
||||
"tty": true
|
||||
"composition": {
|
||||
"tty": true
|
||||
}
|
||||
}
|
||||
|
@ -43,7 +43,7 @@
|
||||
"Type": "journald",
|
||||
"Config": {}
|
||||
},
|
||||
"NetworkMode": "1011165_default",
|
||||
"NetworkMode": "aaaaaaaa_default",
|
||||
"PortBindings": {},
|
||||
"RestartPolicy": {
|
||||
"Name": "always",
|
||||
@ -142,6 +142,7 @@
|
||||
"StdinOnce": false,
|
||||
"Env": [
|
||||
"RESIN_APP_ID=1011165",
|
||||
"RESIN_APP_UUID=aaaaaaaa",
|
||||
"RESIN_APP_NAME=supervisortest",
|
||||
"RESIN_SERVICE_NAME=main",
|
||||
"RESIN_DEVICE_UUID=7dadabd4edec3067948d5952c2f2f26f",
|
||||
@ -152,6 +153,7 @@
|
||||
"RESIN_SERVICE_KILL_ME_PATH=/tmp/balena/handover-complete",
|
||||
"RESIN=1",
|
||||
"BALENA_APP_ID=1011165",
|
||||
"BALENA_APP_UUID=aaaaaaaa",
|
||||
"BALENA_APP_NAME=supervisortest",
|
||||
"BALENA_SERVICE_NAME=main",
|
||||
"BALENA_DEVICE_UUID=7dadabd4edec3067948d5952c2f2f26f",
|
||||
@ -179,6 +181,7 @@
|
||||
"OnBuild": null,
|
||||
"Labels": {
|
||||
"io.resin.app-id": "1011165",
|
||||
"io.balena.app-uuid": "aaaaaaaa",
|
||||
"io.resin.architecture": "armv7hf",
|
||||
"io.resin.device-type": "raspberry-pi2",
|
||||
"io.resin.qemu.version": "2.9.0.resin1-arm",
|
||||
@ -206,7 +209,7 @@
|
||||
"IPv6Gateway": "",
|
||||
"MacAddress": "",
|
||||
"Networks": {
|
||||
"1011165_default": {
|
||||
"aaaaaaaa_default": {
|
||||
"IPAMConfig": null,
|
||||
"Links": null,
|
||||
"Aliases": [
|
||||
|
@ -1,56 +0,0 @@
|
||||
|
||||
{
|
||||
"local": {
|
||||
"name": "lingering-frost",
|
||||
"config": {
|
||||
"RESIN_SUPERVISOR_DELTA_VERSION": "3",
|
||||
"RESIN_SUPERVISOR_NATIVE_LOGGER": "true",
|
||||
"RESIN_HOST_CONFIG_arm_64bit": "1",
|
||||
"RESIN_HOST_CONFIG_disable_splash": "1",
|
||||
"RESIN_HOST_CONFIG_dtoverlay": "\"vc4-fkms-v3d\"",
|
||||
"RESIN_HOST_CONFIG_dtparam": "\"i2c_arm=on\",\"spi=on\",\"audio=on\"",
|
||||
"RESIN_HOST_CONFIG_enable_uart": "1",
|
||||
"RESIN_HOST_CONFIG_gpu_mem": "16",
|
||||
"RESIN_SUPERVISOR_DELTA": "1",
|
||||
"RESIN_SUPERVISOR_POLL_INTERVAL": "900000"
|
||||
},
|
||||
"apps": {
|
||||
"1234": {
|
||||
"name": "pi4test",
|
||||
"commit": "d0b7b1d5353c4a1d9d411614caf827f5",
|
||||
"releaseId": 1405939,
|
||||
"services": {
|
||||
"482141": {
|
||||
"privileged": true,
|
||||
"tty": true,
|
||||
"restart": "always",
|
||||
"network_mode": "host",
|
||||
"volumes": [
|
||||
"resin-data:/data"
|
||||
],
|
||||
"labels": {
|
||||
"io.resin.features.dbus": "1",
|
||||
"io.resin.features.firmware": "1",
|
||||
"io.resin.features.kernel-modules": "1",
|
||||
"io.resin.features.resin-api": "1",
|
||||
"io.resin.features.supervisor-api": "1"
|
||||
},
|
||||
"imageId": 2339002,
|
||||
"serviceName": "main",
|
||||
"image": "registry2.balena-cloud.com/v2/f5aff5560e1fb6740a868bfe2e8a4684@sha256:9cd1d09aad181b98067dac95e08f121c3af16426f078c013a485c41a63dc035c",
|
||||
"running": true,
|
||||
"environment": {}
|
||||
}
|
||||
},
|
||||
"volumes": {
|
||||
"resin-data": {}
|
||||
},
|
||||
"networks": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependent": {
|
||||
"apps": {},
|
||||
"devices": {}
|
||||
}
|
||||
}
|
@ -1,820 +0,0 @@
|
||||
// TODO: This file was created by bulk-decaffeinate.
|
||||
// Sanity-check the conversion and remove this comment.
|
||||
|
||||
export let availableImages: any;
|
||||
export let currentState: any;
|
||||
export let targetState: any;
|
||||
|
||||
targetState = [];
|
||||
targetState[0] = {
|
||||
local: {
|
||||
name: 'aDeviceWithDifferentName',
|
||||
config: {
|
||||
RESIN_HOST_CONFIG_gpu_mem: '512',
|
||||
RESIN_HOST_LOG_TO_DISPLAY: '1',
|
||||
},
|
||||
apps: {
|
||||
1234: {
|
||||
appId: 1234,
|
||||
name: 'superapp',
|
||||
commit: 'afafafa',
|
||||
releaseId: 2,
|
||||
services: {
|
||||
'23': {
|
||||
appId: 1234,
|
||||
serviceName: 'aservice',
|
||||
commit: 'afafafa',
|
||||
imageId: 12345,
|
||||
image: 'registry2.resin.io/superapp/edfabc:latest',
|
||||
environment: {
|
||||
FOO: 'bar',
|
||||
},
|
||||
privileged: false,
|
||||
volumes: [],
|
||||
labels: {},
|
||||
running: true,
|
||||
},
|
||||
'24': {
|
||||
appId: 1234,
|
||||
serviceName: 'anotherService',
|
||||
commit: 'afafafa',
|
||||
imageId: 12346,
|
||||
image: 'registry2.resin.io/superapp/afaff:latest',
|
||||
environment: {
|
||||
FOO: 'bro',
|
||||
},
|
||||
volumes: [],
|
||||
privileged: false,
|
||||
labels: {},
|
||||
running: true,
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
dependent: { apps: {}, devices: {} },
|
||||
};
|
||||
|
||||
targetState[1] = {
|
||||
local: {
|
||||
name: 'aDeviceWithDifferentName',
|
||||
config: {
|
||||
RESIN_HOST_CONFIG_gpu_mem: '512',
|
||||
RESIN_HOST_LOG_TO_DISPLAY: '1',
|
||||
},
|
||||
apps: {
|
||||
1234: {
|
||||
appId: 1234,
|
||||
name: 'superapp',
|
||||
commit: 'afafafa',
|
||||
releaseId: 2,
|
||||
services: {
|
||||
'23': {
|
||||
appId: 1234,
|
||||
serviceName: 'aservice',
|
||||
commit: 'afafafa',
|
||||
imageId: 12345,
|
||||
image: 'registry2.resin.io/superapp/edfabc:latest',
|
||||
environment: {
|
||||
FOO: 'bar',
|
||||
ADDITIONAL_ENV_VAR: 'foo',
|
||||
},
|
||||
privileged: false,
|
||||
volumes: [],
|
||||
labels: {},
|
||||
running: true,
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
dependent: { apps: {}, devices: {} },
|
||||
};
|
||||
|
||||
targetState[2] = {
|
||||
local: {
|
||||
name: 'aDeviceWithDifferentName',
|
||||
config: {
|
||||
RESIN_HOST_CONFIG_gpu_mem: '512',
|
||||
RESIN_HOST_LOG_TO_DISPLAY: '1',
|
||||
},
|
||||
apps: {
|
||||
1234: {
|
||||
appId: 1234,
|
||||
name: 'superapp',
|
||||
commit: 'afafafa',
|
||||
releaseId: 2,
|
||||
services: {
|
||||
'23': {
|
||||
appId: 1234,
|
||||
serviceName: 'aservice',
|
||||
commit: 'afafafa',
|
||||
imageId: 12345,
|
||||
image: 'registry2.resin.io/superapp/edfabc:latest',
|
||||
environment: {
|
||||
FOO: 'bar',
|
||||
ADDITIONAL_ENV_VAR: 'foo',
|
||||
},
|
||||
privileged: false,
|
||||
volumes: [],
|
||||
labels: {},
|
||||
running: true,
|
||||
},
|
||||
'24': {
|
||||
appId: 1234,
|
||||
serviceName: 'anotherService',
|
||||
commit: 'afafafa',
|
||||
imageId: 12347,
|
||||
image: 'registry2.resin.io/superapp/foooo:latest',
|
||||
depends_on: ['aservice'],
|
||||
environment: {
|
||||
FOO: 'bro',
|
||||
ADDITIONAL_ENV_VAR: 'foo',
|
||||
},
|
||||
volumes: [],
|
||||
privileged: false,
|
||||
labels: {},
|
||||
running: true,
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
dependent: { apps: {}, devices: {} },
|
||||
};
|
||||
|
||||
targetState[3] = {
|
||||
local: {
|
||||
name: 'aDeviceWithDifferentName',
|
||||
config: {
|
||||
RESIN_HOST_CONFIG_gpu_mem: '512',
|
||||
RESIN_HOST_LOG_TO_DISPLAY: '1',
|
||||
},
|
||||
apps: {
|
||||
1234: {
|
||||
appId: 1234,
|
||||
name: 'superapp',
|
||||
commit: 'afafafa',
|
||||
releaseId: 2,
|
||||
services: {
|
||||
'23': {
|
||||
appId: 1234,
|
||||
serviceName: 'aservice',
|
||||
commit: 'afafafa',
|
||||
imageId: 12345,
|
||||
image: 'registry2.resin.io/superapp/edfabc:latest',
|
||||
environment: {
|
||||
FOO: 'bar',
|
||||
ADDITIONAL_ENV_VAR: 'foo',
|
||||
},
|
||||
privileged: false,
|
||||
volumes: [],
|
||||
labels: {},
|
||||
running: true,
|
||||
},
|
||||
'24': {
|
||||
appId: 1234,
|
||||
serviceName: 'anotherService',
|
||||
commit: 'afafafa',
|
||||
imageId: 12347,
|
||||
image: 'registry2.resin.io/superapp/foooo:latest',
|
||||
environment: {
|
||||
FOO: 'bro',
|
||||
ADDITIONAL_ENV_VAR: 'foo',
|
||||
},
|
||||
volumes: [],
|
||||
privileged: false,
|
||||
labels: {
|
||||
'io.resin.update.strategy': 'kill-then-download',
|
||||
},
|
||||
running: true,
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
dependent: { apps: {}, devices: {} },
|
||||
};
|
||||
|
||||
targetState[4] = {
|
||||
local: {
|
||||
name: 'aDeviceWithDifferentName',
|
||||
config: {
|
||||
RESIN_HOST_CONFIG_gpu_mem: '512',
|
||||
RESIN_HOST_LOG_TO_DISPLAY: '1',
|
||||
},
|
||||
apps: {
|
||||
1234: {
|
||||
appId: 1234,
|
||||
name: 'superapp',
|
||||
commit: 'afafafa',
|
||||
releaseId: 2,
|
||||
services: {
|
||||
'23': {
|
||||
appId: 1234,
|
||||
serviceName: 'aservice',
|
||||
commit: 'afafafa',
|
||||
imageId: 12345,
|
||||
image: 'registry2.resin.io/superapp/edfabc:latest',
|
||||
environment: {
|
||||
FOO: 'THIS VALUE CHANGED',
|
||||
ADDITIONAL_ENV_VAR: 'foo',
|
||||
},
|
||||
privileged: false,
|
||||
volumes: [],
|
||||
labels: {},
|
||||
running: true,
|
||||
},
|
||||
'24': {
|
||||
appId: 1234,
|
||||
serviceName: 'anotherService',
|
||||
commit: 'afafafa',
|
||||
imageId: 12347,
|
||||
image: 'registry2.resin.io/superapp/foooo:latest',
|
||||
depends_on: ['aservice'],
|
||||
environment: {
|
||||
FOO: 'bro',
|
||||
ADDITIONAL_ENV_VAR: 'foo',
|
||||
},
|
||||
volumes: [],
|
||||
privileged: false,
|
||||
labels: {},
|
||||
running: true,
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
dependent: { apps: {}, devices: {} },
|
||||
};
|
||||
|
||||
targetState[5] = {
|
||||
local: {
|
||||
name: 'aDeviceWithDifferentName',
|
||||
config: {
|
||||
RESIN_HOST_CONFIG_gpu_mem: '512',
|
||||
RESIN_HOST_LOG_TO_DISPLAY: '1',
|
||||
},
|
||||
apps: {
|
||||
1234: {
|
||||
appId: 1234,
|
||||
name: 'superapp',
|
||||
commit: 'afafafa',
|
||||
releaseId: 2,
|
||||
services: {
|
||||
'23': {
|
||||
appId: 1234,
|
||||
serviceName: 'aservice',
|
||||
commit: 'afafafa',
|
||||
imageId: 12345,
|
||||
image: 'registry2.resin.io/superapp/edfabc:latest',
|
||||
environment: {
|
||||
FOO: 'THIS VALUE CHANGED',
|
||||
ADDITIONAL_ENV_VAR: 'foo',
|
||||
},
|
||||
privileged: false,
|
||||
volumes: [],
|
||||
labels: {},
|
||||
running: true,
|
||||
},
|
||||
'24': {
|
||||
appId: 1234,
|
||||
serviceName: 'anotherService',
|
||||
commit: 'afafafa',
|
||||
imageId: 12347,
|
||||
image: 'registry2.resin.io/superapp/foooo:latest',
|
||||
environment: {
|
||||
FOO: 'bro',
|
||||
ADDITIONAL_ENV_VAR: 'foo',
|
||||
},
|
||||
volumes: [],
|
||||
privileged: false,
|
||||
labels: {},
|
||||
running: true,
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
dependent: { apps: {}, devices: {} },
|
||||
};
|
||||
|
||||
targetState[6] = {
|
||||
local: {
|
||||
name: 'volumeTest',
|
||||
config: {},
|
||||
apps: {
|
||||
12345: {
|
||||
appId: 12345,
|
||||
name: 'volumeApp',
|
||||
commit: 'asd',
|
||||
releaseId: 3,
|
||||
services: {},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
dependent: { apps: {}, devices: {} },
|
||||
};
|
||||
|
||||
currentState = [];
|
||||
currentState[0] = {
|
||||
local: {
|
||||
name: 'aDeviceWithDifferentName',
|
||||
config: {
|
||||
RESIN_HOST_CONFIG_gpu_mem: '512',
|
||||
RESIN_HOST_LOG_TO_DISPLAY: '1',
|
||||
},
|
||||
apps: {
|
||||
1234: {
|
||||
appId: 1234,
|
||||
name: 'superapp',
|
||||
commit: 'afafafa',
|
||||
releaseId: 2,
|
||||
services: {
|
||||
23: {
|
||||
appId: 1234,
|
||||
serviceId: 23,
|
||||
releaseId: 2,
|
||||
commit: 'afafafa',
|
||||
serviceName: 'aservice',
|
||||
imageId: 12345,
|
||||
image: 'id1',
|
||||
environment: {
|
||||
FOO: 'bar',
|
||||
ADDITIONAL_ENV_VAR: 'foo',
|
||||
},
|
||||
privileged: false,
|
||||
restart: 'always',
|
||||
volumes: [
|
||||
'/tmp/balena-supervisor/services/1234/aservice:/tmp/resin',
|
||||
'/tmp/balena-supervisor/services/1234/aservice:/tmp/balena',
|
||||
],
|
||||
labels: {
|
||||
'io.resin.app-id': '1234',
|
||||
'io.resin.service-id': '23',
|
||||
'io.resin.supervised': 'true',
|
||||
'io.resin.service-name': 'aservice',
|
||||
},
|
||||
running: true,
|
||||
createdAt: new Date(),
|
||||
containerId: '1',
|
||||
networkMode: 'default',
|
||||
networks: { default: { aliases: ['aservice'] } },
|
||||
command: ['someCommand'],
|
||||
entrypoint: ['theEntrypoint'],
|
||||
},
|
||||
24: {
|
||||
appId: 1234,
|
||||
serviceId: 24,
|
||||
releaseId: 2,
|
||||
commit: 'afafafa',
|
||||
serviceName: 'anotherService',
|
||||
imageId: 12346,
|
||||
image: 'id0',
|
||||
environment: {
|
||||
FOO: 'bro',
|
||||
ADDITIONAL_ENV_VAR: 'foo',
|
||||
},
|
||||
volumes: [
|
||||
'/tmp/balena-supervisor/services/1234/anotherService:/tmp/resin',
|
||||
'/tmp/balena-supervisor/services/1234/anotherService:/tmp/balena',
|
||||
],
|
||||
privileged: false,
|
||||
restart: 'always',
|
||||
labels: {
|
||||
'io.resin.app-id': '1234',
|
||||
'io.resin.service-id': '24',
|
||||
'io.resin.supervised': 'true',
|
||||
'io.resin.service-name': 'anotherService',
|
||||
},
|
||||
running: false,
|
||||
createdAt: new Date(),
|
||||
containerId: '2',
|
||||
networkMode: 'default',
|
||||
networks: { default: { aliases: ['anotherService'] } },
|
||||
command: ['someCommand'],
|
||||
entrypoint: ['theEntrypoint'],
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: { default: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
dependent: { apps: {}, devices: {} },
|
||||
};
|
||||
|
||||
currentState[1] = {
|
||||
local: {
|
||||
name: 'aDeviceWithDifferentName',
|
||||
config: {
|
||||
RESIN_HOST_CONFIG_gpu_mem: '512',
|
||||
RESIN_HOST_LOG_TO_DISPLAY: '1',
|
||||
},
|
||||
apps: {
|
||||
1234: {
|
||||
appId: 1234,
|
||||
name: 'superapp',
|
||||
commit: 'afafafa',
|
||||
releaseId: 2,
|
||||
services: {},
|
||||
volumes: {},
|
||||
networks: { default: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
dependent: { apps: {}, devices: {} },
|
||||
};
|
||||
|
||||
currentState[2] = {
|
||||
local: {
|
||||
name: 'aDeviceWithDifferentName',
|
||||
config: {
|
||||
RESIN_HOST_CONFIG_gpu_mem: '512',
|
||||
RESIN_HOST_LOG_TO_DISPLAY: '1',
|
||||
},
|
||||
apps: {
|
||||
1234: {
|
||||
appId: 1234,
|
||||
name: 'superapp',
|
||||
commit: 'afafafa',
|
||||
releaseId: 2,
|
||||
services: {
|
||||
23: {
|
||||
appId: 1234,
|
||||
serviceId: 23,
|
||||
releaseId: 2,
|
||||
commit: 'afafafa',
|
||||
expose: [],
|
||||
ports: [],
|
||||
serviceName: 'aservice',
|
||||
imageId: 12345,
|
||||
image: 'id1',
|
||||
environment: {
|
||||
FOO: 'THIS VALUE CHANGED',
|
||||
ADDITIONAL_ENV_VAR: 'foo',
|
||||
},
|
||||
privileged: false,
|
||||
restart: 'always',
|
||||
volumes: [
|
||||
'/tmp/balena-supervisor/services/1234/aservice:/tmp/resin',
|
||||
'/tmp/balena-supervisor/services/1234/aservice:/tmp/balena',
|
||||
],
|
||||
labels: {
|
||||
'io.resin.app-id': '1234',
|
||||
'io.resin.service-id': '23',
|
||||
'io.resin.supervised': 'true',
|
||||
'io.resin.service-name': 'aservice',
|
||||
},
|
||||
running: true,
|
||||
createdAt: new Date(),
|
||||
containerId: '1',
|
||||
networkMode: 'default',
|
||||
networks: { default: { aliases: ['aservice'] } },
|
||||
command: ['someCommand'],
|
||||
entrypoint: ['theEntrypoint'],
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: { default: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
dependent: { apps: {}, devices: {} },
|
||||
};
|
||||
|
||||
currentState[3] = {
|
||||
local: {
|
||||
name: 'aDeviceWithDifferentName',
|
||||
config: {
|
||||
RESIN_HOST_CONFIG_gpu_mem: '512',
|
||||
RESIN_HOST_LOG_TO_DISPLAY: '1',
|
||||
},
|
||||
apps: {
|
||||
1234: {
|
||||
appId: 1234,
|
||||
name: 'superapp',
|
||||
commit: 'afafafa',
|
||||
releaseId: 2,
|
||||
services: {
|
||||
23: {
|
||||
appId: 1234,
|
||||
serviceId: 23,
|
||||
serviceName: 'aservice',
|
||||
imageId: 12345,
|
||||
releaseId: 2,
|
||||
commit: 'afafafa',
|
||||
expose: [],
|
||||
ports: [],
|
||||
image: 'id1',
|
||||
environment: {
|
||||
FOO: 'THIS VALUE CHANGED',
|
||||
ADDITIONAL_ENV_VAR: 'foo',
|
||||
},
|
||||
privileged: false,
|
||||
restart: 'always',
|
||||
volumes: [
|
||||
'/tmp/balena-supervisor/services/1234/aservice:/tmp/resin',
|
||||
'/tmp/balena-supervisor/services/1234/aservice:/tmp/balena',
|
||||
],
|
||||
labels: {
|
||||
'io.resin.app-id': '1234',
|
||||
'io.resin.service-id': '23',
|
||||
'io.resin.supervised': 'true',
|
||||
'io.resin.service-name': 'aservice',
|
||||
},
|
||||
running: true,
|
||||
createdAt: new Date(0),
|
||||
containerId: '1',
|
||||
networkMode: 'default',
|
||||
networks: { default: { aliases: ['aservice'] } },
|
||||
command: ['someCommand'],
|
||||
entrypoint: ['theEntrypoint'],
|
||||
},
|
||||
24: {
|
||||
appId: 1234,
|
||||
serviceId: 23,
|
||||
serviceName: 'aservice',
|
||||
imageId: 12345,
|
||||
releaseId: 2,
|
||||
commit: 'afafafa',
|
||||
expose: [],
|
||||
ports: [],
|
||||
image: 'id1',
|
||||
environment: {
|
||||
FOO: 'THIS VALUE CHANGED',
|
||||
ADDITIONAL_ENV_VAR: 'foo',
|
||||
},
|
||||
privileged: false,
|
||||
restart: 'always',
|
||||
volumes: [
|
||||
'/tmp/balena-supervisor/services/1234/aservice:/tmp/resin',
|
||||
'/tmp/balena-supervisor/services/1234/aservice:/tmp/balena',
|
||||
],
|
||||
labels: {
|
||||
'io.resin.app-id': '1234',
|
||||
'io.resin.service-id': '23',
|
||||
'io.resin.supervised': 'true',
|
||||
'io.resin.service-name': 'aservice',
|
||||
},
|
||||
running: true,
|
||||
createdAt: new Date(1),
|
||||
containerId: '2',
|
||||
networkMode: 'default',
|
||||
networks: { default: { aliases: ['aservice'] } },
|
||||
command: ['someCommand'],
|
||||
entrypoint: ['theEntrypoint'],
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: { default: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
dependent: { apps: {}, devices: {} },
|
||||
};
|
||||
|
||||
currentState[4] = {
|
||||
local: {
|
||||
name: 'aDeviceWithDifferentName',
|
||||
config: {
|
||||
RESIN_HOST_CONFIG_gpu_mem: '512',
|
||||
RESIN_HOST_LOG_TO_DISPLAY: '1',
|
||||
},
|
||||
apps: {
|
||||
1234: {
|
||||
appId: 1234,
|
||||
name: 'superapp',
|
||||
commit: 'afafafa',
|
||||
releaseId: 2,
|
||||
services: {
|
||||
24: {
|
||||
appId: 1234,
|
||||
serviceId: 24,
|
||||
releaseId: 2,
|
||||
commit: 'afafafa',
|
||||
serviceName: 'anotherService',
|
||||
imageId: 12346,
|
||||
image: 'id0',
|
||||
environment: {
|
||||
FOO: 'bro',
|
||||
ADDITIONAL_ENV_VAR: 'foo',
|
||||
},
|
||||
volumes: [
|
||||
'/tmp/balena-supervisor/services/1234/anotherService:/tmp/resin',
|
||||
'/tmp/balena-supervisor/services/1234/anotherService:/tmp/balena',
|
||||
],
|
||||
privileged: false,
|
||||
restart: 'always',
|
||||
labels: {
|
||||
'io.resin.app-id': '1234',
|
||||
'io.resin.service-id': '24',
|
||||
'io.resin.supervised': 'true',
|
||||
'io.resin.service-name': 'anotherService',
|
||||
},
|
||||
running: false,
|
||||
createdAt: new Date(),
|
||||
containerId: '2',
|
||||
networkMode: 'default',
|
||||
networks: { default: { aliases: ['aservice'] } },
|
||||
command: ['someCommand'],
|
||||
entrypoint: ['theEntrypoint'],
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: { default: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
dependent: { apps: {}, devices: {} },
|
||||
};
|
||||
|
||||
currentState[5] = {
|
||||
local: {
|
||||
name: 'volumeTest',
|
||||
config: {},
|
||||
apps: {
|
||||
12345: {
|
||||
appId: 12345,
|
||||
name: 'volumeApp',
|
||||
commit: 'asd',
|
||||
releaseId: 3,
|
||||
services: {},
|
||||
volumes: {},
|
||||
networks: { default: {} },
|
||||
},
|
||||
12: {
|
||||
appId: 12,
|
||||
name: 'previous-app',
|
||||
commit: '123',
|
||||
releaseId: 10,
|
||||
services: {},
|
||||
networks: {},
|
||||
volumes: {
|
||||
my_volume: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dependent: { apps: {}, devices: {} },
|
||||
};
|
||||
|
||||
currentState[6] = {
|
||||
local: {
|
||||
name: 'aDeviceWithDifferentName',
|
||||
config: {
|
||||
RESIN_HOST_CONFIG_gpu_mem: '512',
|
||||
RESIN_HOST_LOG_TO_DISPLAY: '1',
|
||||
},
|
||||
apps: {
|
||||
1234: {
|
||||
appId: 1234,
|
||||
name: 'superapp',
|
||||
commit: 'afafafa',
|
||||
releaseId: 2,
|
||||
services: {
|
||||
23: {
|
||||
appId: 1234,
|
||||
serviceId: 23,
|
||||
releaseId: 2,
|
||||
commit: 'afafafa',
|
||||
serviceName: 'aservice',
|
||||
imageId: 12345,
|
||||
image: 'id1',
|
||||
environment: {
|
||||
FOO: 'bar',
|
||||
ADDITIONAL_ENV_VAR: 'foo',
|
||||
},
|
||||
privileged: false,
|
||||
restart: 'always',
|
||||
volumes: [
|
||||
'/tmp/balena-supervisor/services/1234/aservice:/tmp/resin',
|
||||
'/tmp/balena-supervisor/services/1234/aservice:/tmp/balena',
|
||||
],
|
||||
labels: {
|
||||
'io.resin.app-id': '1234',
|
||||
'io.resin.service-id': '23',
|
||||
'io.resin.supervised': 'true',
|
||||
'io.resin.service-name': 'aservice',
|
||||
},
|
||||
running: true,
|
||||
createdAt: new Date(),
|
||||
containerId: '1',
|
||||
networkMode: 'default',
|
||||
networks: { default: { aliases: ['aservice'] } },
|
||||
command: ['someCommand'],
|
||||
entrypoint: ['theEntrypoint'],
|
||||
},
|
||||
24: {
|
||||
appId: 1234,
|
||||
serviceId: 24,
|
||||
releaseId: 2,
|
||||
commit: 'afafafa',
|
||||
serviceName: 'anotherService',
|
||||
imageId: 12346,
|
||||
image: 'id0',
|
||||
environment: {
|
||||
FOO: 'bro',
|
||||
ADDITIONAL_ENV_VAR: 'foo',
|
||||
},
|
||||
volumes: [
|
||||
'/tmp/balena-supervisor/services/1234/anotherService:/tmp/resin',
|
||||
'/tmp/balena-supervisor/services/1234/anotherService:/tmp/balena',
|
||||
],
|
||||
privileged: false,
|
||||
restart: 'always',
|
||||
labels: {
|
||||
'io.resin.app-id': '1234',
|
||||
'io.resin.service-id': '24',
|
||||
'io.resin.supervised': 'true',
|
||||
'io.resin.service-name': 'anotherService',
|
||||
},
|
||||
running: true,
|
||||
createdAt: new Date(),
|
||||
containerId: '2',
|
||||
networkMode: 'default',
|
||||
networks: { default: { aliases: ['anotherService'] } },
|
||||
command: ['someCommand'],
|
||||
entrypoint: ['theEntrypoint'],
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: { default: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
dependent: { apps: {}, devices: {} },
|
||||
};
|
||||
|
||||
availableImages = [];
|
||||
availableImages[0] = [
|
||||
{
|
||||
name: 'registry2.resin.io/superapp/afaff:latest',
|
||||
appId: 1234,
|
||||
serviceId: 24,
|
||||
serviceName: 'anotherService',
|
||||
imageId: 12346,
|
||||
releaseId: 2,
|
||||
dependent: 0,
|
||||
dockerImageId: 'id0',
|
||||
},
|
||||
{
|
||||
name: 'registry2.resin.io/superapp/edfabc:latest',
|
||||
appId: 1234,
|
||||
serviceId: 23,
|
||||
serviceName: 'aservice',
|
||||
imageId: 12345,
|
||||
releaseId: 2,
|
||||
dependent: 0,
|
||||
dockerImageId: 'id1',
|
||||
},
|
||||
];
|
||||
availableImages[1] = [
|
||||
{
|
||||
name: 'registry2.resin.io/superapp/foooo:latest',
|
||||
appId: 1234,
|
||||
serviceId: 24,
|
||||
serviceName: 'anotherService',
|
||||
imageId: 12347,
|
||||
releaseId: 2,
|
||||
dependent: 0,
|
||||
dockerImageId: 'id2',
|
||||
},
|
||||
{
|
||||
name: 'registry2.resin.io/superapp/edfabc:latest',
|
||||
appId: 1234,
|
||||
serviceId: 23,
|
||||
serviceName: 'aservice',
|
||||
imageId: 12345,
|
||||
releaseId: 2,
|
||||
dependent: 0,
|
||||
dockerImageId: 'id1',
|
||||
},
|
||||
];
|
||||
|
||||
availableImages[2] = [
|
||||
{
|
||||
name: 'registry2.resin.io/superapp/foooo:latest',
|
||||
appId: 1234,
|
||||
serviceId: 24,
|
||||
serviceName: 'anotherService',
|
||||
imageId: 12347,
|
||||
releaseId: 2,
|
||||
dependent: 0,
|
||||
dockerImageId: 'id2',
|
||||
},
|
||||
];
|
@ -10,7 +10,6 @@ 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;
|
||||
@ -45,14 +44,10 @@ function unmockManagers() {
|
||||
}
|
||||
|
||||
export function mockImages(
|
||||
downloading: number[],
|
||||
_downloading: number[],
|
||||
cleanup: boolean,
|
||||
available: imageManager.Image[],
|
||||
) {
|
||||
// @ts-expect-error Assigning to a RO property
|
||||
imageManager.getDownloadingImageIds = () => {
|
||||
return downloading;
|
||||
};
|
||||
// @ts-expect-error Assigning to a RO property
|
||||
imageManager.isCleanupNeeded = async () => cleanup;
|
||||
// @ts-expect-error Assigning to a RO property
|
||||
@ -60,8 +55,6 @@ export function mockImages(
|
||||
}
|
||||
|
||||
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
|
||||
|
@ -4,7 +4,6 @@ import rewire = require('rewire');
|
||||
|
||||
import { unlinkAll } from '../../src/lib/fs-utils';
|
||||
import * as applicationManager from '../../src/compose/application-manager';
|
||||
import * as networkManager from '../../src/compose/network-manager';
|
||||
import * as serviceManager from '../../src/compose/service-manager';
|
||||
import * as volumeManager from '../../src/compose/volume-manager';
|
||||
import * as commitStore from '../../src/compose/commit';
|
||||
@ -185,35 +184,24 @@ function buildRoutes(): Router {
|
||||
}
|
||||
|
||||
// TO-DO: Create a cleaner way to restore previous values.
|
||||
const originalNetGetAll = networkManager.getAllByAppId;
|
||||
const originalVolGetAll = volumeManager.getAllByAppId;
|
||||
const originalSvcGetAppId = serviceManager.getAllByAppId;
|
||||
const originalSvcGetStatus = serviceManager.getStatus;
|
||||
const originalSvcGetStatus = serviceManager.getState;
|
||||
const originalReadyForUpdates = apiBinder.__get__('readyForUpdates');
|
||||
|
||||
function setupStubs() {
|
||||
apiBinder.__set__('readyForUpdates', true);
|
||||
// @ts-expect-error Assigning to a RO property
|
||||
networkManager.getAllByAppId = async () => STUBBED_VALUES.networks;
|
||||
// @ts-expect-error Assigning to a RO property
|
||||
volumeManager.getAllByAppId = async () => STUBBED_VALUES.volumes;
|
||||
// @ts-expect-error Assigning to a RO property
|
||||
serviceManager.getStatus = async () => STUBBED_VALUES.services;
|
||||
// @ts-expect-error Assigning to a RO property
|
||||
serviceManager.getAllByAppId = async (appId) =>
|
||||
_.filter(STUBBED_VALUES.services, (service) => service.appId === appId);
|
||||
serviceManager.getState = async () => STUBBED_VALUES.services;
|
||||
}
|
||||
|
||||
function restoreStubs() {
|
||||
apiBinder.__set__('readyForUpdates', originalReadyForUpdates);
|
||||
// @ts-expect-error Assigning to a RO property
|
||||
networkManager.getAllByAppId = originalNetGetAll;
|
||||
// @ts-expect-error Assigning to a RO property
|
||||
volumeManager.getAllByAppId = originalVolGetAll;
|
||||
// @ts-expect-error Assigning to a RO property
|
||||
serviceManager.getStatus = originalSvcGetStatus;
|
||||
// @ts-expect-error Assigning to a RO property
|
||||
serviceManager.getAllByAppId = originalSvcGetAppId;
|
||||
serviceManager.getState = originalSvcGetStatus;
|
||||
}
|
||||
|
||||
export = {
|
||||
|
@ -26,10 +26,12 @@ function createApp({
|
||||
volumes = [] as Volume[],
|
||||
isTarget = false,
|
||||
appId = 1,
|
||||
appUuid = 'appuuid',
|
||||
} = {}) {
|
||||
return new App(
|
||||
{
|
||||
appId,
|
||||
appUuid,
|
||||
services,
|
||||
networks: networks.reduce(
|
||||
(res, net) => ({ ...res, [net.name]: net }),
|
||||
@ -44,6 +46,7 @@ function createApp({
|
||||
async function createService(
|
||||
{
|
||||
appId = 1,
|
||||
appUuid = 'appuuid',
|
||||
serviceName = 'test',
|
||||
commit = 'test-commit',
|
||||
...conf
|
||||
@ -53,8 +56,10 @@ async function createService(
|
||||
const svc = await Service.fromComposeObject(
|
||||
{
|
||||
appId,
|
||||
appUuid,
|
||||
serviceName,
|
||||
commit,
|
||||
running: true,
|
||||
...conf,
|
||||
},
|
||||
options,
|
||||
@ -70,14 +75,18 @@ async function createService(
|
||||
function createImage(
|
||||
{
|
||||
appId = 1,
|
||||
appUuid = 'appuuid',
|
||||
dependent = 0,
|
||||
name = 'test-image',
|
||||
serviceName = 'test',
|
||||
commit = 'test-commit',
|
||||
...extra
|
||||
} = {} as Partial<Image>,
|
||||
) {
|
||||
return {
|
||||
appId,
|
||||
appUuid,
|
||||
commit,
|
||||
dependent,
|
||||
name,
|
||||
serviceName,
|
||||
@ -106,7 +115,7 @@ function expectNoStep(action: CompositionStepAction, steps: CompositionStep[]) {
|
||||
expectSteps(action, steps, 0, 0);
|
||||
}
|
||||
|
||||
const defaultNetwork = Network.fromComposeObject('default', 1, {});
|
||||
const defaultNetwork = Network.fromComposeObject('default', 1, 'appuuid', {});
|
||||
|
||||
describe('compose/app', () => {
|
||||
before(() => {
|
||||
@ -137,7 +146,7 @@ describe('compose/app', () => {
|
||||
// Setup current and target apps
|
||||
const current = createApp();
|
||||
const target = createApp({
|
||||
volumes: [Volume.fromComposeObject('test-volume', 1, {})],
|
||||
volumes: [Volume.fromComposeObject('test-volume', 1, 'deadbeef')],
|
||||
isTarget: true,
|
||||
});
|
||||
|
||||
@ -155,8 +164,8 @@ describe('compose/app', () => {
|
||||
const current = createApp();
|
||||
const target = createApp({
|
||||
volumes: [
|
||||
Volume.fromComposeObject('test-volume', 1, {}),
|
||||
Volume.fromComposeObject('test-volume-2', 1, {}),
|
||||
Volume.fromComposeObject('test-volume', 1, 'deadbeef'),
|
||||
Volume.fromComposeObject('test-volume-2', 1, 'deadbeef'),
|
||||
],
|
||||
isTarget: true,
|
||||
});
|
||||
@ -185,12 +194,12 @@ describe('compose/app', () => {
|
||||
it('should not infer a volume remove step when the app is still referenced', () => {
|
||||
const current = createApp({
|
||||
volumes: [
|
||||
Volume.fromComposeObject('test-volume', 1, {}),
|
||||
Volume.fromComposeObject('test-volume-2', 1, {}),
|
||||
Volume.fromComposeObject('test-volume', 1, 'deadbeef'),
|
||||
Volume.fromComposeObject('test-volume-2', 1, 'deadbeef'),
|
||||
],
|
||||
});
|
||||
const target = createApp({
|
||||
volumes: [Volume.fromComposeObject('test-volume-2', 1, {})],
|
||||
volumes: [Volume.fromComposeObject('test-volume-2', 1, 'deadbeef')],
|
||||
isTarget: true,
|
||||
});
|
||||
|
||||
@ -200,11 +209,11 @@ describe('compose/app', () => {
|
||||
|
||||
it('should correctly infer volume recreation steps', () => {
|
||||
const current = createApp({
|
||||
volumes: [Volume.fromComposeObject('test-volume', 1, {})],
|
||||
volumes: [Volume.fromComposeObject('test-volume', 1, 'deadbeef')],
|
||||
});
|
||||
const target = createApp({
|
||||
volumes: [
|
||||
Volume.fromComposeObject('test-volume', 1, {
|
||||
Volume.fromComposeObject('test-volume', 1, 'deadbeef', {
|
||||
labels: { test: 'test' },
|
||||
}),
|
||||
],
|
||||
@ -220,8 +229,12 @@ describe('compose/app', () => {
|
||||
const [removalStep] = expectSteps('removeVolume', stepsForRemoval);
|
||||
expect(removalStep)
|
||||
.to.have.property('current')
|
||||
.that.has.property('config')
|
||||
.that.deep.includes({ labels: { 'io.balena.supervised': 'true' } });
|
||||
.that.has.property('name')
|
||||
.that.equals('test-volume');
|
||||
expect(removalStep)
|
||||
.to.have.property('current')
|
||||
.that.has.property('appId')
|
||||
.that.equals(1);
|
||||
|
||||
// we are assuming that after the execution steps the current state of the
|
||||
// app will look like this
|
||||
@ -240,7 +253,11 @@ describe('compose/app', () => {
|
||||
.to.have.property('target')
|
||||
.that.has.property('config')
|
||||
.that.deep.includes({
|
||||
labels: { 'io.balena.supervised': 'true', test: 'test' },
|
||||
labels: {
|
||||
'io.balena.supervised': 'true',
|
||||
'io.balena.app-uuid': 'deadbeef',
|
||||
test: 'test',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@ -248,19 +265,19 @@ describe('compose/app', () => {
|
||||
const current = createApp({
|
||||
services: [
|
||||
await createService({
|
||||
volumes: ['test-volume:/data'],
|
||||
composition: { volumes: ['test-volume:/data'] },
|
||||
}),
|
||||
],
|
||||
volumes: [Volume.fromComposeObject('test-volume', 1, {})],
|
||||
volumes: [Volume.fromComposeObject('test-volume', 1, 'deadbeef')],
|
||||
});
|
||||
const target = createApp({
|
||||
services: [
|
||||
await createService({
|
||||
volumes: ['test-volume:/data'],
|
||||
composition: { volumes: ['test-volume:/data'] },
|
||||
}),
|
||||
],
|
||||
volumes: [
|
||||
Volume.fromComposeObject('test-volume', 1, {
|
||||
Volume.fromComposeObject('test-volume', 1, 'deadbeef', {
|
||||
labels: { test: 'test' },
|
||||
}),
|
||||
],
|
||||
@ -278,7 +295,7 @@ describe('compose/app', () => {
|
||||
|
||||
it('should correctly infer to remove an app volumes when the app is being removed', async () => {
|
||||
const current = createApp({
|
||||
volumes: [Volume.fromComposeObject('test-volume', 1, {})],
|
||||
volumes: [Volume.fromComposeObject('test-volume', 1, 'deadbeef')],
|
||||
});
|
||||
|
||||
const steps = await current.stepsToRemoveApp(defaultContext);
|
||||
@ -291,17 +308,17 @@ describe('compose/app', () => {
|
||||
|
||||
it('should not output a kill step for a service which is already stopping when changing a volume', async () => {
|
||||
const service = await createService({
|
||||
volumes: ['test-volume:/data'],
|
||||
composition: { volumes: ['test-volume:/data'] },
|
||||
});
|
||||
service.status = 'Stopping';
|
||||
const current = createApp({
|
||||
services: [service],
|
||||
volumes: [Volume.fromComposeObject('test-volume', 1, {})],
|
||||
volumes: [Volume.fromComposeObject('test-volume', 1, 'deadbeef')],
|
||||
});
|
||||
const target = createApp({
|
||||
services: [service],
|
||||
volumes: [
|
||||
Volume.fromComposeObject('test-volume', 1, {
|
||||
Volume.fromComposeObject('test-volume', 1, 'deadbeef', {
|
||||
labels: { test: 'test' },
|
||||
}),
|
||||
],
|
||||
@ -314,10 +331,12 @@ describe('compose/app', () => {
|
||||
|
||||
it('should generate the correct step sequence for a volume purge request', async () => {
|
||||
const service = await createService({
|
||||
volumes: ['db-volume:/data'],
|
||||
appId: 1,
|
||||
appUuid: 'deadbeef',
|
||||
image: 'test-image',
|
||||
composition: { volumes: ['db-volume:/data'] },
|
||||
});
|
||||
const volume = Volume.fromComposeObject('db-volume', service.appId, {});
|
||||
const volume = Volume.fromComposeObject('db-volume', 1, 'deadbeef');
|
||||
const contextWithImages = {
|
||||
...defaultContext,
|
||||
...{
|
||||
@ -415,7 +434,7 @@ describe('compose/app', () => {
|
||||
it('should correctly infer a network create step', () => {
|
||||
const current = createApp({ networks: [] });
|
||||
const target = createApp({
|
||||
networks: [Network.fromComposeObject('default', 1, {})],
|
||||
networks: [Network.fromComposeObject('default', 1, 'deadbeef', {})],
|
||||
isTarget: true,
|
||||
});
|
||||
|
||||
@ -429,7 +448,9 @@ describe('compose/app', () => {
|
||||
|
||||
it('should correctly infer a network remove step', () => {
|
||||
const current = createApp({
|
||||
networks: [Network.fromComposeObject('test-network', 1, {})],
|
||||
networks: [
|
||||
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
|
||||
],
|
||||
isTarget: true,
|
||||
});
|
||||
const target = createApp({ networks: [], isTarget: true });
|
||||
@ -446,8 +467,8 @@ describe('compose/app', () => {
|
||||
it('should correctly infer more than one network removal step', () => {
|
||||
const current = createApp({
|
||||
networks: [
|
||||
Network.fromComposeObject('test-network', 1, {}),
|
||||
Network.fromComposeObject('test-network-2', 1, {}),
|
||||
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
|
||||
Network.fromComposeObject('test-network-2', 1, 'deadbeef', {}),
|
||||
],
|
||||
isTarget: true,
|
||||
});
|
||||
@ -467,11 +488,13 @@ describe('compose/app', () => {
|
||||
|
||||
it('should correctly infer a network recreation step', () => {
|
||||
const current = createApp({
|
||||
networks: [Network.fromComposeObject('test-network', 1, {})],
|
||||
networks: [
|
||||
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
|
||||
],
|
||||
});
|
||||
const target = createApp({
|
||||
networks: [
|
||||
Network.fromComposeObject('test-network', 1, {
|
||||
Network.fromComposeObject('test-network', 1, 'deadbeef', {
|
||||
labels: { TEST: 'TEST' },
|
||||
}),
|
||||
],
|
||||
@ -511,16 +534,28 @@ describe('compose/app', () => {
|
||||
expect(createNetworkStep)
|
||||
.to.have.property('target')
|
||||
.that.has.property('config')
|
||||
.that.deep.includes({ labels: { TEST: 'TEST' } });
|
||||
.that.deep.includes({
|
||||
labels: { TEST: 'TEST', 'io.balena.app-id': '1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should kill dependencies of networks before removing', async () => {
|
||||
const current = createApp({
|
||||
services: [await createService({ networks: { 'test-network': {} } })],
|
||||
networks: [Network.fromComposeObject('test-network', 1, {})],
|
||||
appUuid: 'deadbeef',
|
||||
services: [
|
||||
await createService({
|
||||
appId: 1,
|
||||
appUuid: 'deadbeef',
|
||||
composition: { networks: ['test-network'] },
|
||||
}),
|
||||
],
|
||||
networks: [
|
||||
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
|
||||
],
|
||||
});
|
||||
const target = createApp({
|
||||
services: [await createService()],
|
||||
appUuid: 'deadbeef',
|
||||
services: [await createService({ appUuid: 'deadbeef' })],
|
||||
networks: [],
|
||||
isTarget: true,
|
||||
});
|
||||
@ -535,13 +570,21 @@ describe('compose/app', () => {
|
||||
|
||||
it('should kill dependencies of networks before changing config', async () => {
|
||||
const current = createApp({
|
||||
services: [await createService({ networks: { 'test-network': {} } })],
|
||||
networks: [Network.fromComposeObject('test-network', 1, {})],
|
||||
services: [
|
||||
await createService({
|
||||
composition: { networks: ['test-network'] },
|
||||
}),
|
||||
],
|
||||
networks: [Network.fromComposeObject('test-network', 1, 'appuuid', {})],
|
||||
});
|
||||
const target = createApp({
|
||||
services: [await createService({ networks: { 'test-network': {} } })],
|
||||
services: [
|
||||
await createService({
|
||||
composition: { networks: { 'test-network': {} } },
|
||||
}),
|
||||
],
|
||||
networks: [
|
||||
Network.fromComposeObject('test-network', 1, {
|
||||
Network.fromComposeObject('test-network', 1, 'appuuid', {
|
||||
labels: { test: 'test' },
|
||||
}),
|
||||
],
|
||||
@ -574,7 +617,7 @@ describe('compose/app', () => {
|
||||
|
||||
it('should not create the default network if it already exists', () => {
|
||||
const current = createApp({
|
||||
networks: [Network.fromComposeObject('default', 1, {})],
|
||||
networks: [Network.fromComposeObject('default', 1, 'deadbeef', {})],
|
||||
});
|
||||
const target = createApp({ networks: [], isTarget: true });
|
||||
|
||||
@ -586,17 +629,17 @@ describe('compose/app', () => {
|
||||
});
|
||||
|
||||
describe('service state behavior', () => {
|
||||
it('should create a kill step for service which is no longer referenced', async () => {
|
||||
it('should create a kill step for a service which is no longer referenced', async () => {
|
||||
const current = createApp({
|
||||
services: [
|
||||
await createService({ appId: 1, serviceName: 'main' }),
|
||||
await createService({ appId: 1, serviceName: 'aux' }),
|
||||
],
|
||||
networks: [Network.fromComposeObject('test-network', 1, {})],
|
||||
networks: [defaultNetwork],
|
||||
});
|
||||
const target = createApp({
|
||||
services: [await createService({ appId: 1, serviceName: 'main' })],
|
||||
networks: [Network.fromComposeObject('test-network', 1, {})],
|
||||
networks: [defaultNetwork],
|
||||
isTarget: true,
|
||||
});
|
||||
|
||||
@ -731,7 +774,7 @@ describe('compose/app', () => {
|
||||
const current = createApp({
|
||||
services: [
|
||||
await createService(
|
||||
{ restart: 'no', running: false },
|
||||
{ composition: { restart: 'no' }, running: false },
|
||||
{ state: { containerId: 'run_once' } },
|
||||
),
|
||||
],
|
||||
@ -746,7 +789,7 @@ describe('compose/app', () => {
|
||||
const target = createApp({
|
||||
services: [
|
||||
await createService(
|
||||
{ restart: 'no', running: false },
|
||||
{ composition: { restart: 'no' }, running: false },
|
||||
{ state: { containerId: 'run_once' } },
|
||||
),
|
||||
],
|
||||
@ -779,9 +822,9 @@ describe('compose/app', () => {
|
||||
const target = createApp({
|
||||
services: [
|
||||
await createService({
|
||||
privileged: true,
|
||||
appId: 1,
|
||||
serviceName: 'main',
|
||||
composition: { privileged: true },
|
||||
}),
|
||||
],
|
||||
networks: [defaultNetwork],
|
||||
@ -802,7 +845,7 @@ describe('compose/app', () => {
|
||||
const intermediate = createApp({
|
||||
services: [],
|
||||
// Default network was already created
|
||||
networks: [Network.fromComposeObject('default', 1, {})],
|
||||
networks: [defaultNetwork],
|
||||
});
|
||||
|
||||
// now should see a 'start'
|
||||
@ -851,7 +894,9 @@ describe('compose/app', () => {
|
||||
await createService({
|
||||
appId: 1,
|
||||
serviceName: 'main',
|
||||
dependsOn: ['dep'],
|
||||
composition: {
|
||||
depends_on: ['dep'],
|
||||
},
|
||||
}),
|
||||
await createService({
|
||||
appId: 1,
|
||||
@ -1022,10 +1067,12 @@ describe('compose/app', () => {
|
||||
services: [
|
||||
await createService({
|
||||
image: 'main-image',
|
||||
dependsOn: ['dep'],
|
||||
appId: 1,
|
||||
serviceName: 'main',
|
||||
commit: 'old-release',
|
||||
composition: {
|
||||
depends_on: ['dep'],
|
||||
},
|
||||
}),
|
||||
await createService({
|
||||
image: 'dep-image',
|
||||
@ -1040,10 +1087,12 @@ describe('compose/app', () => {
|
||||
services: [
|
||||
await createService({
|
||||
image: 'main-image-2',
|
||||
dependsOn: ['dep'],
|
||||
appId: 1,
|
||||
serviceName: 'main',
|
||||
commit: 'new-release',
|
||||
composition: {
|
||||
depends_on: ['dep'],
|
||||
},
|
||||
}),
|
||||
await createService({
|
||||
image: 'dep-image-2',
|
||||
@ -1134,7 +1183,9 @@ describe('compose/app', () => {
|
||||
services: [
|
||||
await createService({
|
||||
labels,
|
||||
privileged: true,
|
||||
composition: {
|
||||
privileged: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
isTarget: true,
|
||||
@ -1150,8 +1201,16 @@ describe('compose/app', () => {
|
||||
it('should not start a service when a network it depends on is not ready', async () => {
|
||||
const current = createApp({ networks: [defaultNetwork] });
|
||||
const target = createApp({
|
||||
services: [await createService({ networks: ['test'], appId: 1 })],
|
||||
networks: [defaultNetwork, Network.fromComposeObject('test', 1, {})],
|
||||
services: [
|
||||
await createService({
|
||||
composition: { networks: ['test'] },
|
||||
appId: 1,
|
||||
}),
|
||||
],
|
||||
networks: [
|
||||
defaultNetwork,
|
||||
Network.fromComposeObject('test', 1, 'appuuid', {}),
|
||||
],
|
||||
isTarget: true,
|
||||
});
|
||||
|
||||
|
@ -4,6 +4,7 @@ import { stub } from 'sinon';
|
||||
import App from '../../../src/compose/app';
|
||||
import * as applicationManager from '../../../src/compose/application-manager';
|
||||
import * as imageManager from '../../../src/compose/images';
|
||||
import * as serviceManager from '../../../src/compose/service-manager';
|
||||
import { Image } from '../../../src/compose/images';
|
||||
import Network from '../../../src/compose/network';
|
||||
import * as networkManager from '../../../src/compose/network-manager';
|
||||
@ -15,11 +16,12 @@ import { InstancedAppState } from '../../../src/types/state';
|
||||
|
||||
import * as dbHelper from '../../lib/db-helper';
|
||||
|
||||
const DEFAULT_NETWORK = Network.fromComposeObject('default', 1, {});
|
||||
const DEFAULT_NETWORK = Network.fromComposeObject('default', 1, 'appuuid', {});
|
||||
|
||||
async function createService(
|
||||
{
|
||||
appId = 1,
|
||||
appUuid = 'appuuid',
|
||||
serviceName = 'main',
|
||||
commit = 'main-commit',
|
||||
...conf
|
||||
@ -29,6 +31,7 @@ async function createService(
|
||||
const svc = await Service.fromComposeObject(
|
||||
{
|
||||
appId,
|
||||
appUuid,
|
||||
serviceName,
|
||||
commit,
|
||||
// db ids should not be used for target state calculation, but images
|
||||
@ -52,23 +55,26 @@ async function createService(
|
||||
function createImage(
|
||||
{
|
||||
appId = 1,
|
||||
dependent = 0,
|
||||
appUuid = 'appuuid',
|
||||
name = 'test-image',
|
||||
serviceName = 'test',
|
||||
serviceName = 'main',
|
||||
commit = 'main-commit',
|
||||
...extra
|
||||
} = {} as Partial<Image>,
|
||||
) {
|
||||
return {
|
||||
appId,
|
||||
dependent,
|
||||
appUuid,
|
||||
name,
|
||||
serviceName,
|
||||
commit,
|
||||
// db ids should not be used for target state calculation, but images
|
||||
// are compared using _.isEqual so leaving this here to have image comparisons
|
||||
// match
|
||||
imageId: 1,
|
||||
releaseId: 1,
|
||||
serviceId: 1,
|
||||
dependent: 0,
|
||||
...extra,
|
||||
} as Image;
|
||||
}
|
||||
@ -130,7 +136,7 @@ function createCurrentState({
|
||||
volumes = [] as Volume[],
|
||||
images = services.map((s) => ({
|
||||
// Infer images from services by default
|
||||
dockerImageId: s.config.image,
|
||||
dockerImageId: s.dockerImageId,
|
||||
...imageManager.imageFromService(s),
|
||||
})) as Image[],
|
||||
downloading = [] as string[],
|
||||
@ -363,12 +369,15 @@ describe('compose/application-manager', () => {
|
||||
containerIdsByAppId,
|
||||
} = createCurrentState({
|
||||
services: [
|
||||
await createService({
|
||||
image: 'image-old',
|
||||
labels,
|
||||
appId: 1,
|
||||
commit: 'old-release',
|
||||
}),
|
||||
await createService(
|
||||
{
|
||||
image: 'image-old',
|
||||
labels,
|
||||
appId: 1,
|
||||
commit: 'old-release',
|
||||
},
|
||||
{ options: { imageInfo: { Id: 'sha256:image-old-id' } } },
|
||||
),
|
||||
],
|
||||
networks: [DEFAULT_NETWORK],
|
||||
});
|
||||
@ -414,12 +423,15 @@ describe('compose/application-manager', () => {
|
||||
containerIdsByAppId,
|
||||
} = createCurrentState({
|
||||
services: [
|
||||
await createService({
|
||||
image: 'image-old',
|
||||
labels,
|
||||
appId: 1,
|
||||
commit: 'old-release',
|
||||
}),
|
||||
await createService(
|
||||
{
|
||||
image: 'image-old',
|
||||
labels,
|
||||
appId: 1,
|
||||
commit: 'old-release',
|
||||
},
|
||||
{ options: { imageInfo: { Id: 'sha256:image-old-id' } } },
|
||||
),
|
||||
],
|
||||
networks: [DEFAULT_NETWORK],
|
||||
});
|
||||
@ -499,10 +511,12 @@ describe('compose/application-manager', () => {
|
||||
services: [
|
||||
await createService({
|
||||
image: 'main-image',
|
||||
dependsOn: ['dep'],
|
||||
appId: 1,
|
||||
commit: 'new-release',
|
||||
serviceName: 'main',
|
||||
composition: {
|
||||
depends_on: ['dep'],
|
||||
},
|
||||
}),
|
||||
await createService({
|
||||
image: 'dep-image',
|
||||
@ -523,10 +537,12 @@ describe('compose/application-manager', () => {
|
||||
} = createCurrentState({
|
||||
services: [
|
||||
await createService({
|
||||
dependsOn: ['dep'],
|
||||
appId: 1,
|
||||
commit: 'old-release',
|
||||
serviceName: 'main',
|
||||
composition: {
|
||||
depends_on: ['dep'],
|
||||
},
|
||||
}),
|
||||
await createService({
|
||||
appId: 1,
|
||||
@ -566,14 +582,18 @@ describe('compose/application-manager', () => {
|
||||
services: [
|
||||
await createService({
|
||||
image: 'main-image',
|
||||
dependsOn: ['dep'],
|
||||
appId: 1,
|
||||
appUuid: 'appuuid',
|
||||
commit: 'new-release',
|
||||
serviceName: 'main',
|
||||
composition: {
|
||||
depends_on: ['dep'],
|
||||
},
|
||||
}),
|
||||
await createService({
|
||||
image: 'dep-image',
|
||||
appId: 1,
|
||||
appUuid: 'appuuid',
|
||||
commit: 'new-release',
|
||||
serviceName: 'dep',
|
||||
}),
|
||||
@ -591,13 +611,17 @@ describe('compose/application-manager', () => {
|
||||
} = createCurrentState({
|
||||
services: [
|
||||
await createService({
|
||||
dependsOn: ['dep'],
|
||||
appId: 1,
|
||||
appUuid: 'appuuid',
|
||||
commit: 'old-release',
|
||||
serviceName: 'main',
|
||||
composition: {
|
||||
depends_on: ['dep'],
|
||||
},
|
||||
}),
|
||||
await createService({
|
||||
appId: 1,
|
||||
appUuid: 'appuuid',
|
||||
commit: 'old-release',
|
||||
serviceName: 'dep',
|
||||
}),
|
||||
@ -607,13 +631,17 @@ describe('compose/application-manager', () => {
|
||||
// Both images have been downloaded
|
||||
createImage({
|
||||
appId: 1,
|
||||
appUuid: 'appuuid',
|
||||
name: 'main-image',
|
||||
serviceName: 'main',
|
||||
commit: 'new-release',
|
||||
}),
|
||||
createImage({
|
||||
appId: 1,
|
||||
appUuid: 'appuuid',
|
||||
name: 'dep-image',
|
||||
serviceName: 'dep',
|
||||
commit: 'new-release',
|
||||
}),
|
||||
],
|
||||
});
|
||||
@ -647,9 +675,11 @@ describe('compose/application-manager', () => {
|
||||
services: [
|
||||
await createService({
|
||||
image: 'main-image',
|
||||
dependsOn: ['dep'],
|
||||
serviceName: 'main',
|
||||
commit: 'new-release',
|
||||
composition: {
|
||||
depends_on: ['dep'],
|
||||
},
|
||||
}),
|
||||
await createService({
|
||||
image: 'dep-image',
|
||||
@ -673,14 +703,14 @@ describe('compose/application-manager', () => {
|
||||
images: [
|
||||
// Both images have been downloaded
|
||||
createImage({
|
||||
appId: 1,
|
||||
name: 'main-image',
|
||||
serviceName: 'main',
|
||||
commit: 'new-release',
|
||||
}),
|
||||
createImage({
|
||||
appId: 1,
|
||||
name: 'dep-image',
|
||||
serviceName: 'dep',
|
||||
commit: 'new-release',
|
||||
}),
|
||||
],
|
||||
});
|
||||
@ -711,9 +741,11 @@ describe('compose/application-manager', () => {
|
||||
services: [
|
||||
await createService({
|
||||
image: 'main-image',
|
||||
dependsOn: ['dep'],
|
||||
serviceName: 'main',
|
||||
commit: 'new-release',
|
||||
composition: {
|
||||
depends_on: ['dep'],
|
||||
},
|
||||
}),
|
||||
await createService({
|
||||
image: 'dep-image',
|
||||
@ -743,14 +775,14 @@ describe('compose/application-manager', () => {
|
||||
images: [
|
||||
// Both images have been downloaded
|
||||
createImage({
|
||||
appId: 1,
|
||||
name: 'main-image',
|
||||
serviceName: 'main',
|
||||
commit: 'new-release',
|
||||
}),
|
||||
createImage({
|
||||
appId: 1,
|
||||
name: 'dep-image',
|
||||
serviceName: 'dep',
|
||||
commit: 'new-release',
|
||||
}),
|
||||
],
|
||||
});
|
||||
@ -793,7 +825,6 @@ describe('compose/application-manager', () => {
|
||||
images: [
|
||||
// Image has been downloaded
|
||||
createImage({
|
||||
appId: 1,
|
||||
name: 'main-image',
|
||||
serviceName: 'main',
|
||||
}),
|
||||
@ -836,7 +867,7 @@ describe('compose/application-manager', () => {
|
||||
} = createCurrentState({
|
||||
services: [],
|
||||
networks: [DEFAULT_NETWORK],
|
||||
volumes: [Volume.fromComposeObject('test-volume', 1, {})],
|
||||
volumes: [Volume.fromComposeObject('test-volume', 1, 'deadbeef')],
|
||||
});
|
||||
|
||||
const steps = await applicationManager.inferNextSteps(
|
||||
@ -863,7 +894,7 @@ describe('compose/application-manager', () => {
|
||||
services: [],
|
||||
networks: [],
|
||||
// Volume with different id
|
||||
volumes: [Volume.fromComposeObject('test-volume', 2, {})],
|
||||
volumes: [Volume.fromComposeObject('test-volume', 2, 'deadbeef')],
|
||||
});
|
||||
|
||||
const steps = await applicationManager.inferNextSteps(
|
||||
@ -1159,19 +1190,21 @@ describe('compose/application-manager', () => {
|
||||
running: true,
|
||||
image: 'main-image-1',
|
||||
appId: 1,
|
||||
appUuid: 'app-one',
|
||||
commit: 'commit-for-app-1',
|
||||
}),
|
||||
await createService({
|
||||
running: true,
|
||||
image: 'main-image-2',
|
||||
appId: 2,
|
||||
appUuid: 'app-two',
|
||||
commit: 'commit-for-app-2',
|
||||
}),
|
||||
],
|
||||
networks: [
|
||||
// Default networks for two apps
|
||||
Network.fromComposeObject('default', 1, {}),
|
||||
Network.fromComposeObject('default', 2, {}),
|
||||
Network.fromComposeObject('default', 1, 'app-one', {}),
|
||||
Network.fromComposeObject('default', 2, 'app-two', {}),
|
||||
],
|
||||
},
|
||||
true,
|
||||
@ -1185,19 +1218,23 @@ describe('compose/application-manager', () => {
|
||||
services: [],
|
||||
networks: [
|
||||
// Default networks for two apps
|
||||
Network.fromComposeObject('default', 1, {}),
|
||||
Network.fromComposeObject('default', 2, {}),
|
||||
Network.fromComposeObject('default', 1, 'app-one', {}),
|
||||
Network.fromComposeObject('default', 2, 'app-two', {}),
|
||||
],
|
||||
images: [
|
||||
createImage({
|
||||
name: 'main-image-1',
|
||||
appId: 1,
|
||||
appUuid: 'app-one',
|
||||
serviceName: 'main',
|
||||
commit: 'commit-for-app-1',
|
||||
}),
|
||||
createImage({
|
||||
name: 'main-image-2',
|
||||
appId: 2,
|
||||
appUuid: 'app-two',
|
||||
serviceName: 'main',
|
||||
commit: 'commit-for-app-2',
|
||||
}),
|
||||
],
|
||||
});
|
||||
@ -1230,4 +1267,241 @@ describe('compose/application-manager', () => {
|
||||
),
|
||||
).to.have.lengthOf(1);
|
||||
});
|
||||
|
||||
describe('getting applications current state', () => {
|
||||
let getImagesState: sinon.SinonStub;
|
||||
let getServicesState: sinon.SinonStub;
|
||||
|
||||
before(() => {
|
||||
getImagesState = sinon.stub(imageManager, 'getState');
|
||||
getServicesState = sinon.stub(serviceManager, 'getState');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getImagesState.reset();
|
||||
getServicesState.reset();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
getImagesState.restore();
|
||||
getServicesState.restore();
|
||||
});
|
||||
|
||||
it('reports the state of images if no service is available', async () => {
|
||||
getImagesState.resolves([
|
||||
{
|
||||
name: 'ubuntu:latest',
|
||||
commit: 'latestrelease',
|
||||
appUuid: 'myapp',
|
||||
serviceName: 'ubuntu',
|
||||
status: 'Downloaded',
|
||||
},
|
||||
{
|
||||
name: 'alpine:latest',
|
||||
commit: 'latestrelease',
|
||||
appUuid: 'myapp',
|
||||
serviceName: 'alpine',
|
||||
status: 'Downloading',
|
||||
downloadProgress: 50,
|
||||
},
|
||||
{
|
||||
name: 'fedora:latest',
|
||||
commit: 'newrelease',
|
||||
appUuid: 'fedora',
|
||||
serviceName: 'fedora',
|
||||
status: 'Downloading',
|
||||
downloadProgress: 75,
|
||||
},
|
||||
{
|
||||
name: 'fedora:older',
|
||||
commit: 'oldrelease',
|
||||
appUuid: 'fedora',
|
||||
serviceName: 'fedora',
|
||||
status: 'Downloaded',
|
||||
},
|
||||
]);
|
||||
getServicesState.resolves([]);
|
||||
|
||||
expect(await applicationManager.getState()).to.deep.equal({
|
||||
myapp: {
|
||||
releases: {
|
||||
latestrelease: {
|
||||
services: {
|
||||
ubuntu: {
|
||||
image: 'ubuntu:latest',
|
||||
status: 'Downloaded',
|
||||
},
|
||||
alpine: {
|
||||
image: 'alpine:latest',
|
||||
status: 'Downloading',
|
||||
download_progress: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
fedora: {
|
||||
releases: {
|
||||
oldrelease: {
|
||||
services: {
|
||||
fedora: {
|
||||
image: 'fedora:older',
|
||||
status: 'Downloaded',
|
||||
},
|
||||
},
|
||||
},
|
||||
newrelease: {
|
||||
services: {
|
||||
fedora: {
|
||||
image: 'fedora:latest',
|
||||
status: 'Downloading',
|
||||
download_progress: 75,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('augments the service data with image data', async () => {
|
||||
getImagesState.resolves([
|
||||
{
|
||||
name: 'ubuntu:latest',
|
||||
commit: 'latestrelease',
|
||||
appUuid: 'myapp',
|
||||
serviceName: 'ubuntu',
|
||||
status: 'Downloaded',
|
||||
},
|
||||
{
|
||||
name: 'alpine:latest',
|
||||
commit: 'latestrelease',
|
||||
appUuid: 'myapp',
|
||||
serviceName: 'alpine',
|
||||
status: 'Downloading',
|
||||
downloadProgress: 50,
|
||||
},
|
||||
{
|
||||
name: 'fedora:older',
|
||||
commit: 'oldrelease',
|
||||
appUuid: 'fedora',
|
||||
serviceName: 'fedora',
|
||||
status: 'Downloaded',
|
||||
},
|
||||
]);
|
||||
getServicesState.resolves([
|
||||
{
|
||||
commit: 'latestrelease',
|
||||
appUuid: 'myapp',
|
||||
serviceName: 'ubuntu',
|
||||
status: 'Running',
|
||||
createdAt: new Date('2021-09-01T13:00:00'),
|
||||
},
|
||||
{
|
||||
commit: 'oldrelease',
|
||||
serviceName: 'fedora',
|
||||
status: 'Stopped',
|
||||
createdAt: new Date('2021-09-01T12:00:00'),
|
||||
},
|
||||
{
|
||||
// Service without an image should not show on the final state
|
||||
appUuid: 'debian',
|
||||
commit: 'otherrelease',
|
||||
serviceName: 'debian',
|
||||
status: 'Stopped',
|
||||
createdAt: new Date('2021-09-01T12:00:00'),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(await applicationManager.getState()).to.deep.equal({
|
||||
myapp: {
|
||||
releases: {
|
||||
latestrelease: {
|
||||
services: {
|
||||
ubuntu: {
|
||||
image: 'ubuntu:latest',
|
||||
status: 'Running',
|
||||
},
|
||||
alpine: {
|
||||
image: 'alpine:latest',
|
||||
status: 'Downloading',
|
||||
download_progress: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
fedora: {
|
||||
releases: {
|
||||
oldrelease: {
|
||||
services: {
|
||||
fedora: {
|
||||
image: 'fedora:older',
|
||||
status: 'Stopped',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('reports handover state if multiple services are running for the same app', async () => {
|
||||
getImagesState.resolves([
|
||||
{
|
||||
name: 'alpine:3.13',
|
||||
commit: 'latestrelease',
|
||||
appUuid: 'myapp',
|
||||
serviceName: 'alpine',
|
||||
status: 'Downloaded',
|
||||
},
|
||||
{
|
||||
name: 'alpine:3.12',
|
||||
commit: 'oldrelease',
|
||||
appUuid: 'myapp',
|
||||
serviceName: 'alpine',
|
||||
status: 'Downloaded',
|
||||
},
|
||||
]);
|
||||
getServicesState.resolves([
|
||||
{
|
||||
commit: 'latestrelease',
|
||||
appUuid: 'myapp',
|
||||
serviceName: 'alpine',
|
||||
status: 'Running',
|
||||
createdAt: new Date('2021-09-01T13:00:00'),
|
||||
},
|
||||
{
|
||||
commit: 'oldrelease',
|
||||
appUuid: 'myapp',
|
||||
serviceName: 'alpine',
|
||||
status: 'Running',
|
||||
createdAt: new Date('2021-09-01T12:00:00'),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(await applicationManager.getState()).to.deep.equal({
|
||||
myapp: {
|
||||
releases: {
|
||||
latestrelease: {
|
||||
services: {
|
||||
alpine: {
|
||||
image: 'alpine:3.13',
|
||||
status: 'Awaiting handover',
|
||||
},
|
||||
},
|
||||
},
|
||||
oldrelease: {
|
||||
services: {
|
||||
alpine: {
|
||||
image: 'alpine:3.12',
|
||||
status: 'Handing over',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -7,6 +7,26 @@ import * as sinon from 'sinon';
|
||||
|
||||
import log from '../../../src/lib/supervisor-console';
|
||||
|
||||
// TODO: this code is duplicated in multiple tests
|
||||
// create a test module with all helper functions like this
|
||||
function createDBImage(
|
||||
{
|
||||
appId = 1,
|
||||
name = 'test-image',
|
||||
serviceName = 'test',
|
||||
dependent = 0,
|
||||
...extra
|
||||
} = {} as Partial<imageManager.Image>,
|
||||
) {
|
||||
return {
|
||||
appId,
|
||||
dependent,
|
||||
name,
|
||||
serviceName,
|
||||
...extra,
|
||||
} as imageManager.Image;
|
||||
}
|
||||
|
||||
describe('compose/images', () => {
|
||||
let testDb: dbHelper.TestDatabase;
|
||||
before(async () => {
|
||||
@ -36,19 +56,12 @@ describe('compose/images', () => {
|
||||
});
|
||||
|
||||
it('finds image by matching digest on the database', async () => {
|
||||
const dbImage = {
|
||||
id: 246,
|
||||
const dbImage = createDBImage({
|
||||
name:
|
||||
'registry2.balena-cloud.com/v2/aaaaa@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
|
||||
appId: 1658654,
|
||||
serviceId: 650325,
|
||||
serviceName: 'app_1',
|
||||
imageId: 2693229,
|
||||
releaseId: 1524186,
|
||||
dependent: 0,
|
||||
dockerImageId:
|
||||
'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7',
|
||||
};
|
||||
});
|
||||
await testDb.models('image').insert([dbImage]);
|
||||
|
||||
const images = [
|
||||
@ -72,8 +85,8 @@ describe('compose/images', () => {
|
||||
await expect(mockerode.getImage(dbImage.name).inspect()).to.be.rejected;
|
||||
|
||||
// Looking up the image by id should succeed
|
||||
await expect(mockerode.getImage(dbImage.dockerImageId).inspect()).to.not
|
||||
.be.rejected;
|
||||
await expect(mockerode.getImage(dbImage.dockerImageId!).inspect()).to
|
||||
.not.be.rejected;
|
||||
|
||||
// The image is found
|
||||
expect(await imageManager.inspectByName(dbImage.name))
|
||||
@ -126,18 +139,11 @@ describe('compose/images', () => {
|
||||
});
|
||||
|
||||
it('finds image by tag on the database', async () => {
|
||||
const dbImage = {
|
||||
id: 246,
|
||||
const dbImage = createDBImage({
|
||||
name: 'some-image:some-tag',
|
||||
appId: 1658654,
|
||||
serviceId: 650325,
|
||||
serviceName: 'app_1',
|
||||
imageId: 2693229,
|
||||
releaseId: 1524186,
|
||||
dependent: 0,
|
||||
dockerImageId:
|
||||
'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7',
|
||||
};
|
||||
});
|
||||
await testDb.models('image').insert([dbImage]);
|
||||
|
||||
const images = [
|
||||
@ -245,53 +251,29 @@ describe('compose/images', () => {
|
||||
|
||||
it('returns all images in both the database and the engine', async () => {
|
||||
await testDb.models('image').insert([
|
||||
{
|
||||
id: 1,
|
||||
createDBImage({
|
||||
name: 'first-image-name:first-image-tag',
|
||||
appId: 1,
|
||||
serviceId: 1,
|
||||
serviceName: 'app_1',
|
||||
imageId: 1,
|
||||
releaseId: 1,
|
||||
dependent: 0,
|
||||
dockerImageId: 'sha256:first-image-id',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
}),
|
||||
createDBImage({
|
||||
name: 'second-image-name:second-image-tag',
|
||||
appId: 2,
|
||||
serviceId: 2,
|
||||
serviceName: 'app_2',
|
||||
imageId: 2,
|
||||
releaseId: 2,
|
||||
dependent: 0,
|
||||
dockerImageId: 'sha256:second-image-id',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
}),
|
||||
createDBImage({
|
||||
name:
|
||||
'registry2.balena-cloud.com/v2/three@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf558',
|
||||
appId: 3,
|
||||
serviceId: 3,
|
||||
serviceName: 'app_3',
|
||||
imageId: 3,
|
||||
releaseId: 3,
|
||||
dependent: 0,
|
||||
// Third image has different name but same docker id
|
||||
dockerImageId: 'sha256:second-image-id',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
}),
|
||||
createDBImage({
|
||||
name: 'fourth-image-name:fourth-image-tag',
|
||||
appId: 4,
|
||||
serviceId: 4,
|
||||
serviceName: 'app_4',
|
||||
imageId: 4,
|
||||
releaseId: 4,
|
||||
dependent: 0,
|
||||
// The fourth image exists on the engine but with the wrong id
|
||||
dockerImageId: 'sha256:fourth-image-id',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const images = [
|
||||
@ -336,16 +318,9 @@ describe('compose/images', () => {
|
||||
|
||||
it('removes a single legacy db images without dockerImageId', async () => {
|
||||
// Legacy images don't have a dockerImageId so they are queried by name
|
||||
const imageToRemove = {
|
||||
id: 246,
|
||||
const imageToRemove = createDBImage({
|
||||
name: 'image-name:image-tag',
|
||||
appId: 1658654,
|
||||
serviceId: 650325,
|
||||
serviceName: 'app_1',
|
||||
imageId: 2693229,
|
||||
releaseId: 1524186,
|
||||
dependent: 0,
|
||||
};
|
||||
});
|
||||
|
||||
await testDb.models('image').insert([imageToRemove]);
|
||||
|
||||
@ -405,34 +380,20 @@ describe('compose/images', () => {
|
||||
|
||||
it('removes image from DB and engine when there is a single DB image with matching name', async () => {
|
||||
// Newer image
|
||||
const imageToRemove = {
|
||||
id: 246,
|
||||
const imageToRemove = createDBImage({
|
||||
name:
|
||||
'registry2.balena-cloud.com/v2/one@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
|
||||
appId: 1658654,
|
||||
serviceId: 650325,
|
||||
serviceName: 'app_1',
|
||||
imageId: 2693229,
|
||||
releaseId: 1524186,
|
||||
dependent: 0,
|
||||
dockerImageId: 'sha256:image-id-one',
|
||||
};
|
||||
});
|
||||
|
||||
// Insert images into the db
|
||||
await testDb.models('image').insert([
|
||||
imageToRemove,
|
||||
{
|
||||
id: 247,
|
||||
createDBImage({
|
||||
name:
|
||||
'registry2.balena-cloud.com/v2/two@sha256:12345a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
|
||||
appId: 1658654,
|
||||
serviceId: 650331,
|
||||
serviceName: 'app_2',
|
||||
imageId: 2693230,
|
||||
releaseId: 1524186,
|
||||
dependent: 0,
|
||||
dockerImageId: 'sha256:image-id-two',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Engine image state
|
||||
@ -507,33 +468,18 @@ describe('compose/images', () => {
|
||||
});
|
||||
|
||||
it('removes the requested image even when there are multiple DB images with same docker ID', async () => {
|
||||
const imageToRemove = {
|
||||
id: 246,
|
||||
const imageToRemove = createDBImage({
|
||||
name:
|
||||
'registry2.balena-cloud.com/v2/one@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
|
||||
appId: 1658654,
|
||||
serviceId: 650325,
|
||||
serviceName: 'app_1',
|
||||
imageId: 2693229,
|
||||
releaseId: 1524186,
|
||||
dependent: 0,
|
||||
dockerImageId: 'sha256:image-id-one',
|
||||
};
|
||||
});
|
||||
|
||||
const imageWithSameDockerImageId = {
|
||||
id: 247,
|
||||
const imageWithSameDockerImageId = createDBImage({
|
||||
name:
|
||||
'registry2.balena-cloud.com/v2/two@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
|
||||
appId: 1658654,
|
||||
serviceId: 650331,
|
||||
serviceName: 'app_2',
|
||||
imageId: 2693230,
|
||||
releaseId: 1524186,
|
||||
dependent: 0,
|
||||
|
||||
// Same imageId
|
||||
dockerImageId: 'sha256:image-id-one',
|
||||
};
|
||||
});
|
||||
|
||||
// Insert images into the db
|
||||
await testDb.models('image').insert([
|
||||
@ -547,7 +493,7 @@ describe('compose/images', () => {
|
||||
// The image to remove
|
||||
createImage(
|
||||
{
|
||||
Id: imageToRemove.dockerImageId,
|
||||
Id: imageToRemove.dockerImageId!,
|
||||
},
|
||||
{
|
||||
References: [imageToRemove.name, imageWithSameDockerImageId.name],
|
||||
@ -570,7 +516,7 @@ describe('compose/images', () => {
|
||||
// Check that the image is on the engine
|
||||
// really checking mockerode behavior
|
||||
await expect(
|
||||
mockerode.getImage(imageToRemove.dockerImageId).inspect(),
|
||||
mockerode.getImage(imageToRemove.dockerImageId!).inspect(),
|
||||
'image exists on the engine before the test',
|
||||
).to.not.be.rejected;
|
||||
|
||||
@ -607,32 +553,18 @@ describe('compose/images', () => {
|
||||
});
|
||||
|
||||
it('removes image from DB by tag when deltas are being used', async () => {
|
||||
const imageToRemove = {
|
||||
id: 246,
|
||||
const imageToRemove = createDBImage({
|
||||
name:
|
||||
'registry2.balena-cloud.com/v2/one@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
|
||||
appId: 1658654,
|
||||
serviceId: 650325,
|
||||
serviceName: 'app_1',
|
||||
imageId: 2693229,
|
||||
releaseId: 1524186,
|
||||
dependent: 0,
|
||||
dockerImageId: 'sha256:image-one-id',
|
||||
};
|
||||
});
|
||||
|
||||
const imageWithSameDockerImageId = {
|
||||
id: 247,
|
||||
const imageWithSameDockerImageId = createDBImage({
|
||||
name:
|
||||
'registry2.balena-cloud.com/v2/two@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
|
||||
appId: 1658654,
|
||||
serviceId: 650331,
|
||||
serviceName: 'app_2',
|
||||
imageId: 2693230,
|
||||
releaseId: 1524186,
|
||||
dependent: 0,
|
||||
// Same docker id
|
||||
dockerImageId: 'sha256:image-one-id',
|
||||
};
|
||||
});
|
||||
|
||||
// Insert images into the db
|
||||
await testDb.models('image').insert([
|
||||
@ -646,7 +578,7 @@ describe('compose/images', () => {
|
||||
// The image to remove
|
||||
createImage(
|
||||
{
|
||||
Id: imageToRemove.dockerImageId,
|
||||
Id: imageToRemove.dockerImageId!,
|
||||
},
|
||||
{
|
||||
References: [
|
||||
@ -663,7 +595,7 @@ describe('compose/images', () => {
|
||||
async (mockerode) => {
|
||||
// Check that the image is on the engine
|
||||
await expect(
|
||||
mockerode.getImage(imageToRemove.dockerImageId).inspect(),
|
||||
mockerode.getImage(imageToRemove.dockerImageId!).inspect(),
|
||||
'image can be found by id before the test',
|
||||
).to.not.be.rejected;
|
||||
|
||||
|
@ -10,10 +10,16 @@ import { log } from '../../../src/lib/supervisor-console';
|
||||
describe('compose/network', () => {
|
||||
describe('creating a network from a compose object', () => {
|
||||
it('creates a default network configuration if no config is given', () => {
|
||||
const network = Network.fromComposeObject('default', 12345, {});
|
||||
const network = Network.fromComposeObject(
|
||||
'default',
|
||||
12345,
|
||||
'deadbeef',
|
||||
{},
|
||||
);
|
||||
|
||||
expect(network.name).to.equal('default');
|
||||
expect(network.appId).to.equal(12345);
|
||||
expect(network.appUuid).to.equal('deadbeef');
|
||||
|
||||
// Default configuration options
|
||||
expect(network.config.driver).to.equal('bridge');
|
||||
@ -23,12 +29,14 @@ describe('compose/network', () => {
|
||||
options: {},
|
||||
});
|
||||
expect(network.config.enableIPv6).to.equal(false);
|
||||
expect(network.config.labels).to.deep.equal({});
|
||||
expect(network.config.labels).to.deep.equal({
|
||||
'io.balena.app-id': '12345',
|
||||
});
|
||||
expect(network.config.options).to.deep.equal({});
|
||||
});
|
||||
|
||||
it('normalizes legacy labels', () => {
|
||||
const network = Network.fromComposeObject('default', 12345, {
|
||||
const network = Network.fromComposeObject('default', 12345, 'deadbeef', {
|
||||
labels: {
|
||||
'io.resin.features.something': '1234',
|
||||
},
|
||||
@ -36,11 +44,12 @@ describe('compose/network', () => {
|
||||
|
||||
expect(network.config.labels).to.deep.equal({
|
||||
'io.balena.features.something': '1234',
|
||||
'io.balena.app-id': '12345',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts valid IPAM configurations', () => {
|
||||
const network0 = Network.fromComposeObject('default', 12345, {
|
||||
const network0 = Network.fromComposeObject('default', 12345, 'deadbeef', {
|
||||
ipam: { driver: 'dummy', config: [], options: {} },
|
||||
});
|
||||
|
||||
@ -51,7 +60,7 @@ describe('compose/network', () => {
|
||||
options: {},
|
||||
});
|
||||
|
||||
const network1 = Network.fromComposeObject('default', 12345, {
|
||||
const network1 = Network.fromComposeObject('default', 12345, 'deadbeef', {
|
||||
ipam: {
|
||||
driver: 'default',
|
||||
config: [
|
||||
@ -84,7 +93,7 @@ describe('compose/network', () => {
|
||||
it('warns about IPAM configuration without both gateway and subnet', () => {
|
||||
const logSpy = sinon.spy(log, 'warn');
|
||||
|
||||
Network.fromComposeObject('default', 12345, {
|
||||
Network.fromComposeObject('default', 12345, 'deadbeef', {
|
||||
ipam: {
|
||||
driver: 'default',
|
||||
config: [
|
||||
@ -103,7 +112,7 @@ describe('compose/network', () => {
|
||||
|
||||
logSpy.resetHistory();
|
||||
|
||||
Network.fromComposeObject('default', 12345, {
|
||||
Network.fromComposeObject('default', 12345, 'deadbeef', {
|
||||
ipam: {
|
||||
driver: 'default',
|
||||
config: [
|
||||
@ -124,7 +133,7 @@ describe('compose/network', () => {
|
||||
});
|
||||
|
||||
it('parses values from a compose object', () => {
|
||||
const network1 = Network.fromComposeObject('default', 12345, {
|
||||
const network1 = Network.fromComposeObject('default', 12345, 'deadbeef', {
|
||||
driver: 'bridge',
|
||||
enable_ipv6: true,
|
||||
internal: false,
|
||||
@ -171,6 +180,7 @@ describe('compose/network', () => {
|
||||
|
||||
expect(dockerConfig.Labels).to.deep.equal({
|
||||
'io.balena.supervised': 'true',
|
||||
'io.balena.app-id': '12345',
|
||||
'com.docker.some-label': 'yes',
|
||||
});
|
||||
|
||||
@ -209,9 +219,17 @@ describe('compose/network', () => {
|
||||
Name: '1234',
|
||||
} as NetworkInspectInfo),
|
||||
).to.throw();
|
||||
|
||||
expect(() =>
|
||||
Network.fromDockerNetwork({
|
||||
Id: 'deadbeef',
|
||||
Name: 'a173bdb734884b778f5cc3dffd18733e_default',
|
||||
Labels: {}, // no app-id
|
||||
} as NetworkInspectInfo),
|
||||
).to.throw();
|
||||
});
|
||||
|
||||
it('creates a network object from a docker network configuration', () => {
|
||||
it('creates a network object from a legacy docker network configuration', () => {
|
||||
const network = Network.fromDockerNetwork({
|
||||
Id: 'deadbeef',
|
||||
Name: '1234_default',
|
||||
@ -233,6 +251,7 @@ describe('compose/network', () => {
|
||||
'com.docker.some-option': 'abcd',
|
||||
} as NetworkInspectInfo['Options'],
|
||||
Labels: {
|
||||
'io.balena.supervised': 'true',
|
||||
'io.balena.features.something': '123',
|
||||
} as NetworkInspectInfo['Labels'],
|
||||
} as NetworkInspectInfo);
|
||||
@ -257,6 +276,56 @@ describe('compose/network', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a network object from a docker network configuration', () => {
|
||||
const network = Network.fromDockerNetwork({
|
||||
Id: 'deadbeef',
|
||||
Name: 'a173bdb734884b778f5cc3dffd18733e_default',
|
||||
Driver: 'bridge',
|
||||
EnableIPv6: true,
|
||||
IPAM: {
|
||||
Driver: 'default',
|
||||
Options: {},
|
||||
Config: [
|
||||
{
|
||||
Subnet: '172.18.0.0/16',
|
||||
Gateway: '172.18.0.1',
|
||||
},
|
||||
],
|
||||
} as NetworkInspectInfo['IPAM'],
|
||||
Internal: true,
|
||||
Containers: {},
|
||||
Options: {
|
||||
'com.docker.some-option': 'abcd',
|
||||
} as NetworkInspectInfo['Options'],
|
||||
Labels: {
|
||||
'io.balena.supervised': 'true',
|
||||
'io.balena.features.something': '123',
|
||||
'io.balena.app-id': '1234',
|
||||
} as NetworkInspectInfo['Labels'],
|
||||
} as NetworkInspectInfo);
|
||||
|
||||
expect(network.appId).to.equal(1234);
|
||||
expect(network.appUuid).to.equal('a173bdb734884b778f5cc3dffd18733e');
|
||||
expect(network.name).to.equal('default');
|
||||
expect(network.config.enableIPv6).to.equal(true);
|
||||
expect(network.config.ipam.driver).to.equal('default');
|
||||
expect(network.config.ipam.options).to.deep.equal({});
|
||||
expect(network.config.ipam.config).to.deep.equal([
|
||||
{
|
||||
subnet: '172.18.0.0/16',
|
||||
gateway: '172.18.0.1',
|
||||
},
|
||||
]);
|
||||
expect(network.config.internal).to.equal(true);
|
||||
expect(network.config.options).to.deep.equal({
|
||||
'com.docker.some-option': 'abcd',
|
||||
});
|
||||
expect(network.config.labels).to.deep.equal({
|
||||
'io.balena.features.something': '123',
|
||||
'io.balena.app-id': '1234',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes legacy label names and excludes supervised label', () => {
|
||||
const network = Network.fromDockerNetwork({
|
||||
Id: 'deadbeef',
|
||||
@ -284,7 +353,7 @@ describe('compose/network', () => {
|
||||
it('creates a docker compose network object from the internal network config', () => {
|
||||
const network = Network.fromDockerNetwork({
|
||||
Id: 'deadbeef',
|
||||
Name: '1234_default',
|
||||
Name: 'a173bdb734884b778f5cc3dffd18733e_default',
|
||||
Driver: 'bridge',
|
||||
EnableIPv6: true,
|
||||
IPAM: {
|
||||
@ -304,9 +373,13 @@ describe('compose/network', () => {
|
||||
} as NetworkInspectInfo['Options'],
|
||||
Labels: {
|
||||
'io.balena.features.something': '123',
|
||||
'io.balena.app-id': '12345',
|
||||
} as NetworkInspectInfo['Labels'],
|
||||
} as NetworkInspectInfo);
|
||||
|
||||
expect(network.appId).to.equal(12345);
|
||||
expect(network.appUuid).to.equal('a173bdb734884b778f5cc3dffd18733e');
|
||||
|
||||
// Convert to compose object
|
||||
const compose = network.toComposeObject();
|
||||
expect(compose.driver).to.equal('bridge');
|
||||
@ -327,23 +400,26 @@ describe('compose/network', () => {
|
||||
});
|
||||
expect(compose.labels).to.deep.equal({
|
||||
'io.balena.features.something': '123',
|
||||
'io.balena.app-id': '12345',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateDockerName', () => {
|
||||
it('creates a proper network name from the user given name and the app id', () => {
|
||||
expect(Network.generateDockerName(12345, 'default')).to.equal(
|
||||
'12345_default',
|
||||
it('creates a proper network name from the user given name and the app uuid', () => {
|
||||
expect(Network.generateDockerName('deadbeef', 'default')).to.equal(
|
||||
'deadbeef_default',
|
||||
);
|
||||
expect(Network.generateDockerName('deadbeef', 'bleh')).to.equal(
|
||||
'deadbeef_bleh',
|
||||
);
|
||||
expect(Network.generateDockerName(12345, 'bleh')).to.equal('12345_bleh');
|
||||
expect(Network.generateDockerName(1, 'default')).to.equal('1_default');
|
||||
});
|
||||
});
|
||||
|
||||
describe('comparing network configurations', () => {
|
||||
it('ignores IPAM configuration', () => {
|
||||
const network = Network.fromComposeObject('default', 12345, {
|
||||
const network = Network.fromComposeObject('default', 12345, 'deadbeef', {
|
||||
ipam: {
|
||||
driver: 'default',
|
||||
config: [
|
||||
@ -357,13 +433,15 @@ describe('compose/network', () => {
|
||||
},
|
||||
});
|
||||
expect(
|
||||
network.isEqualConfig(Network.fromComposeObject('default', 12345, {})),
|
||||
network.isEqualConfig(
|
||||
Network.fromComposeObject('default', 12345, 'deadbeef', {}),
|
||||
),
|
||||
).to.be.true;
|
||||
|
||||
// Only ignores ipam.config, not other ipam elements
|
||||
expect(
|
||||
network.isEqualConfig(
|
||||
Network.fromComposeObject('default', 12345, {
|
||||
Network.fromComposeObject('default', 12345, 'deadbeef', {
|
||||
ipam: { driver: 'aaa' },
|
||||
}),
|
||||
),
|
||||
@ -372,26 +450,61 @@ describe('compose/network', () => {
|
||||
|
||||
it('compares configurations recursively', () => {
|
||||
expect(
|
||||
Network.fromComposeObject('default', 12345, {}).isEqualConfig(
|
||||
Network.fromComposeObject('default', 12345, {}),
|
||||
Network.fromComposeObject(
|
||||
'default',
|
||||
12345,
|
||||
'deadbeef',
|
||||
{},
|
||||
).isEqualConfig(
|
||||
Network.fromComposeObject('default', 12345, 'deadbeef', {}),
|
||||
),
|
||||
).to.be.true;
|
||||
expect(
|
||||
Network.fromComposeObject('default', 12345, {
|
||||
Network.fromComposeObject('default', 12345, 'deadbeef', {
|
||||
driver: 'default',
|
||||
}).isEqualConfig(Network.fromComposeObject('default', 12345, {})),
|
||||
}).isEqualConfig(
|
||||
Network.fromComposeObject('default', 12345, 'deadbeef', {}),
|
||||
),
|
||||
).to.be.false;
|
||||
expect(
|
||||
Network.fromComposeObject('default', 12345, {
|
||||
Network.fromComposeObject('default', 12345, 'deadbeef', {
|
||||
enable_ipv6: true,
|
||||
}).isEqualConfig(Network.fromComposeObject('default', 12345, {})),
|
||||
}).isEqualConfig(
|
||||
Network.fromComposeObject('default', 12345, 'deadbeef', {}),
|
||||
),
|
||||
).to.be.false;
|
||||
expect(
|
||||
Network.fromComposeObject('default', 12345, {
|
||||
Network.fromComposeObject('default', 12345, 'deadbeef', {
|
||||
enable_ipv6: false,
|
||||
internal: false,
|
||||
}).isEqualConfig(
|
||||
Network.fromComposeObject('default', 12345, { internal: true }),
|
||||
Network.fromComposeObject('default', 12345, 'deadbeef', {
|
||||
internal: true,
|
||||
}),
|
||||
),
|
||||
).to.be.false;
|
||||
|
||||
// Comparison of a network without the app-uuid and a network
|
||||
// with uuid has to return false
|
||||
expect(
|
||||
Network.fromComposeObject(
|
||||
'default',
|
||||
12345,
|
||||
'deadbeef',
|
||||
{},
|
||||
).isEqualConfig(
|
||||
Network.fromDockerNetwork({
|
||||
Id: 'deadbeef',
|
||||
Name: '12345_default',
|
||||
IPAM: {
|
||||
Driver: 'default',
|
||||
Options: {},
|
||||
Config: [],
|
||||
} as NetworkInspectInfo['IPAM'],
|
||||
Labels: {
|
||||
'io.balena.supervised': 'true',
|
||||
} as NetworkInspectInfo['Labels'],
|
||||
} as NetworkInspectInfo),
|
||||
),
|
||||
).to.be.false;
|
||||
});
|
||||
@ -400,26 +513,31 @@ describe('compose/network', () => {
|
||||
describe('creating networks', () => {
|
||||
it('creates a new network on the engine with the given data', async () => {
|
||||
await withMockerode(async (mockerode) => {
|
||||
const network = Network.fromComposeObject('default', 12345, {
|
||||
ipam: {
|
||||
driver: 'default',
|
||||
config: [
|
||||
{
|
||||
subnet: '172.20.0.0/16',
|
||||
ip_range: '172.20.10.0/24',
|
||||
gateway: '172.20.0.1',
|
||||
},
|
||||
],
|
||||
options: {},
|
||||
const network = Network.fromComposeObject(
|
||||
'default',
|
||||
12345,
|
||||
'deadbeef',
|
||||
{
|
||||
ipam: {
|
||||
driver: 'default',
|
||||
config: [
|
||||
{
|
||||
subnet: '172.20.0.0/16',
|
||||
ip_range: '172.20.10.0/24',
|
||||
gateway: '172.20.0.1',
|
||||
},
|
||||
],
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
// Create the network
|
||||
await network.create();
|
||||
|
||||
// Check that the create function was called with proper arguments
|
||||
expect(mockerode.createNetwork).to.have.been.calledOnceWith({
|
||||
Name: '12345_default',
|
||||
Name: 'deadbeef_default',
|
||||
Driver: 'bridge',
|
||||
CheckDuplicate: true,
|
||||
IPAM: {
|
||||
@ -437,6 +555,7 @@ describe('compose/network', () => {
|
||||
Internal: false,
|
||||
Labels: {
|
||||
'io.balena.supervised': 'true',
|
||||
'io.balena.app-id': '12345',
|
||||
},
|
||||
Options: {},
|
||||
});
|
||||
@ -445,19 +564,24 @@ describe('compose/network', () => {
|
||||
|
||||
it('throws the error if there is a problem while creating the network', async () => {
|
||||
await withMockerode(async (mockerode) => {
|
||||
const network = Network.fromComposeObject('default', 12345, {
|
||||
ipam: {
|
||||
driver: 'default',
|
||||
config: [
|
||||
{
|
||||
subnet: '172.20.0.0/16',
|
||||
ip_range: '172.20.10.0/24',
|
||||
gateway: '172.20.0.1',
|
||||
},
|
||||
],
|
||||
options: {},
|
||||
const network = Network.fromComposeObject(
|
||||
'default',
|
||||
12345,
|
||||
'deadbeef',
|
||||
{
|
||||
ipam: {
|
||||
driver: 'default',
|
||||
config: [
|
||||
{
|
||||
subnet: '172.20.0.0/16',
|
||||
ip_range: '172.20.10.0/24',
|
||||
gateway: '172.20.0.1',
|
||||
},
|
||||
],
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
// Re-define the dockerode.createNetwork to throw
|
||||
mockerode.createNetwork.rejects('Unknown engine error');
|
||||
@ -471,10 +595,10 @@ describe('compose/network', () => {
|
||||
});
|
||||
|
||||
describe('removing a network', () => {
|
||||
it('removes the network from the engine if it exists', async () => {
|
||||
it('removes the legacy network from the engine if it exists', async () => {
|
||||
// Create a mock network to add to the mock engine
|
||||
const dockerNetwork = createNetwork({
|
||||
Id: 'deadbeef',
|
||||
Id: 'aaaaaaa',
|
||||
Name: '12345_default',
|
||||
});
|
||||
|
||||
@ -484,7 +608,48 @@ describe('compose/network', () => {
|
||||
expect(await mockerode.listNetworks()).to.have.lengthOf(1);
|
||||
|
||||
// Create a dummy network object
|
||||
const network = Network.fromComposeObject('default', 12345, {});
|
||||
const network = Network.fromComposeObject(
|
||||
'default',
|
||||
12345,
|
||||
'deadbeef',
|
||||
{},
|
||||
);
|
||||
|
||||
// Perform the operation
|
||||
await network.remove();
|
||||
|
||||
// The removal step should delete the object from the engine data
|
||||
expect(mockerode.removeNetwork).to.have.been.calledOnceWith(
|
||||
'aaaaaaa',
|
||||
);
|
||||
},
|
||||
{ networks: [dockerNetwork] },
|
||||
);
|
||||
});
|
||||
|
||||
it('removes the network from the engine if it exists', async () => {
|
||||
// Create a mock network to add to the mock engine
|
||||
const dockerNetwork = createNetwork({
|
||||
Id: 'deadbeef',
|
||||
Name: 'a173bdb734884b778f5cc3dffd18733e_default',
|
||||
Labels: {
|
||||
'io.balena.supervised': 'true',
|
||||
'io.balena.app-id': '12345',
|
||||
},
|
||||
});
|
||||
|
||||
await withMockerode(
|
||||
async (mockerode) => {
|
||||
// Check that the engine has the network
|
||||
expect(await mockerode.listNetworks()).to.have.lengthOf(1);
|
||||
|
||||
// Create a dummy network object
|
||||
const network = Network.fromComposeObject(
|
||||
'default',
|
||||
12345,
|
||||
'a173bdb734884b778f5cc3dffd18733e',
|
||||
{},
|
||||
);
|
||||
|
||||
// Perform the operation
|
||||
await network.remove();
|
||||
@ -501,7 +666,7 @@ describe('compose/network', () => {
|
||||
it('ignores the request if the given network does not exist on the engine', async () => {
|
||||
// Create a mock network to add to the mock engine
|
||||
const mockNetwork = createNetwork({
|
||||
Id: 'deadbeef',
|
||||
Id: 'aaaaaaaa',
|
||||
Name: 'some_network',
|
||||
});
|
||||
|
||||
@ -511,7 +676,12 @@ describe('compose/network', () => {
|
||||
expect(await mockerode.listNetworks()).to.have.lengthOf(1);
|
||||
|
||||
// Create a dummy network object
|
||||
const network = Network.fromComposeObject('default', 12345, {});
|
||||
const network = Network.fromComposeObject(
|
||||
'default',
|
||||
12345,
|
||||
'deadbeef',
|
||||
{},
|
||||
);
|
||||
|
||||
// This should not fail
|
||||
await expect(network.remove()).to.not.be.rejected;
|
||||
@ -526,18 +696,29 @@ describe('compose/network', () => {
|
||||
it('throws the error if there is a problem while removing the network', async () => {
|
||||
// Create a mock network to add to the mock engine
|
||||
const mockNetwork = createNetwork({
|
||||
Id: 'deadbeef',
|
||||
Name: '12345_default',
|
||||
Id: 'aaaaaaaa',
|
||||
Name: 'a173bdb734884b778f5cc3dffd18733e_default',
|
||||
Labels: {
|
||||
'io.balena.app-id': '12345',
|
||||
},
|
||||
});
|
||||
|
||||
await withMockerode(
|
||||
async (mockerode) => {
|
||||
// We can change the return value of the mockerode removeNetwork
|
||||
// to have the remove operation fail
|
||||
mockerode.removeNetwork.throws('Failed to remove the network');
|
||||
mockerode.removeNetwork.throws({
|
||||
statusCode: 500,
|
||||
message: 'Failed to remove the network',
|
||||
});
|
||||
|
||||
// Create a dummy network object
|
||||
const network = Network.fromComposeObject('default', 12345, {});
|
||||
const network = Network.fromComposeObject(
|
||||
'default',
|
||||
12345,
|
||||
'a173bdb734884b778f5cc3dffd18733e',
|
||||
{},
|
||||
);
|
||||
|
||||
await expect(network.remove()).to.be.rejected;
|
||||
},
|
||||
|
@ -60,6 +60,7 @@ describe('compose/service', () => {
|
||||
};
|
||||
const service = {
|
||||
appId: '23',
|
||||
appUuid: 'deadbeef',
|
||||
releaseId: 2,
|
||||
serviceId: 3,
|
||||
imageId: 4,
|
||||
@ -78,6 +79,7 @@ describe('compose/service', () => {
|
||||
FOO: 'bar',
|
||||
A_VARIABLE: 'ITS_VALUE',
|
||||
RESIN_APP_ID: '23',
|
||||
RESIN_APP_UUID: 'deadbeef',
|
||||
RESIN_APP_NAME: 'awesomeApp',
|
||||
RESIN_DEVICE_UUID: '1234',
|
||||
RESIN_DEVICE_ARCH: 'amd64',
|
||||
@ -88,6 +90,7 @@ describe('compose/service', () => {
|
||||
RESIN_SERVICE_KILL_ME_PATH: '/tmp/balena/handover-complete',
|
||||
RESIN: '1',
|
||||
BALENA_APP_ID: '23',
|
||||
BALENA_APP_UUID: 'deadbeef',
|
||||
BALENA_APP_NAME: 'awesomeApp',
|
||||
BALENA_DEVICE_UUID: '1234',
|
||||
BALENA_DEVICE_ARCH: 'amd64',
|
||||
@ -127,8 +130,10 @@ describe('compose/service', () => {
|
||||
releaseId: 2,
|
||||
serviceId: 3,
|
||||
imageId: 4,
|
||||
expose: [1000, '243/udp'],
|
||||
ports: ['2344', '2345:2354', '2346:2367/udp'],
|
||||
composition: {
|
||||
expose: [1000, '243/udp'],
|
||||
ports: ['2344', '2345:2354', '2346:2367/udp'],
|
||||
},
|
||||
},
|
||||
{
|
||||
imageInfo: {
|
||||
@ -183,8 +188,10 @@ describe('compose/service', () => {
|
||||
releaseId: 2,
|
||||
serviceId: 3,
|
||||
imageId: 4,
|
||||
expose: [1000, '243/udp'],
|
||||
ports: ['1000-1003:2000-2003'],
|
||||
composition: {
|
||||
expose: [1000, '243/udp'],
|
||||
ports: ['1000-1003:2000-2003'],
|
||||
},
|
||||
},
|
||||
{ appName: 'test' } as any,
|
||||
);
|
||||
@ -236,7 +243,9 @@ describe('compose/service', () => {
|
||||
releaseId: 2,
|
||||
serviceId: 3,
|
||||
imageId: 4,
|
||||
ports: ['5-65536:5-65536/tcp', '5-65536:5-65536/udp'],
|
||||
composition: {
|
||||
ports: ['5-65536:5-65536/tcp', '5-65536:5-65536/udp'],
|
||||
},
|
||||
},
|
||||
{ appName: 'test' } as any,
|
||||
);
|
||||
@ -250,7 +259,9 @@ describe('compose/service', () => {
|
||||
appId: 123456,
|
||||
serviceId: 123456,
|
||||
serviceName: 'test',
|
||||
ports: ['80:80', '100:100'],
|
||||
composition: {
|
||||
ports: ['80:80', '100:100'],
|
||||
},
|
||||
},
|
||||
{ appName: 'test' } as any,
|
||||
);
|
||||
@ -266,12 +277,14 @@ describe('compose/service', () => {
|
||||
appId: 123,
|
||||
serviceId: 123,
|
||||
serviceName: 'test',
|
||||
volumes: [
|
||||
'vol1:vol2',
|
||||
'vol3 :/usr/src/app',
|
||||
'vol4: /usr/src/app',
|
||||
'vol5 : vol6',
|
||||
],
|
||||
composition: {
|
||||
volumes: [
|
||||
'vol1:vol2',
|
||||
'vol3 :/usr/src/app',
|
||||
'vol4: /usr/src/app',
|
||||
'vol5 : vol6',
|
||||
],
|
||||
},
|
||||
},
|
||||
{ appName: 'test' } as any,
|
||||
);
|
||||
@ -296,7 +309,9 @@ describe('compose/service', () => {
|
||||
appId: 123456,
|
||||
serviceId: 123456,
|
||||
serviceName: 'foobar',
|
||||
mem_limit: memLimit,
|
||||
composition: {
|
||||
mem_limit: memLimit,
|
||||
},
|
||||
},
|
||||
{ appName: 'test' } as any,
|
||||
);
|
||||
@ -381,7 +396,9 @@ describe('compose/service', () => {
|
||||
appId: 123456,
|
||||
serviceId: 123456,
|
||||
serviceName: 'foobar',
|
||||
workingDir: workdir,
|
||||
composition: {
|
||||
workingDir: workdir,
|
||||
},
|
||||
},
|
||||
{ appName: 'test' } as any,
|
||||
);
|
||||
@ -412,9 +429,12 @@ describe('compose/service', () => {
|
||||
await Service.fromComposeObject(
|
||||
{
|
||||
appId: 123456,
|
||||
appUuid: 'deadbeef',
|
||||
serviceId: 123456,
|
||||
serviceName: 'test',
|
||||
networks,
|
||||
composition: {
|
||||
networks,
|
||||
},
|
||||
},
|
||||
{ appName: 'test' } as any,
|
||||
);
|
||||
@ -429,7 +449,7 @@ describe('compose/service', () => {
|
||||
).toDockerContainer({ deviceName: 'foo' } as any).NetworkingConfig,
|
||||
).to.deep.equal({
|
||||
EndpointsConfig: {
|
||||
'123456_balena': {
|
||||
deadbeef_balena: {
|
||||
IPAMConfig: {
|
||||
IPv4Address: '1.2.3.4',
|
||||
},
|
||||
@ -451,7 +471,7 @@ describe('compose/service', () => {
|
||||
).toDockerContainer({ deviceName: 'foo' } as any).NetworkingConfig,
|
||||
).to.deep.equal({
|
||||
EndpointsConfig: {
|
||||
'123456_balena': {
|
||||
deadbeef_balena: {
|
||||
IPAMConfig: {
|
||||
IPv4Address: '1.2.3.4',
|
||||
IPv6Address: '5.6.7.8',
|
||||
@ -473,7 +493,9 @@ describe('compose/service', () => {
|
||||
appId: 1,
|
||||
serviceId: 1,
|
||||
serviceName: 'test',
|
||||
dns: ['8.8.8.8', '1.1.1.1'],
|
||||
composition: {
|
||||
dns: ['8.8.8.8', '1.1.1.1'],
|
||||
},
|
||||
},
|
||||
{ appName: 'test' } as any,
|
||||
);
|
||||
@ -482,7 +504,9 @@ describe('compose/service', () => {
|
||||
appId: 1,
|
||||
serviceId: 1,
|
||||
serviceName: 'test',
|
||||
dns: ['8.8.8.8', '1.1.1.1'],
|
||||
composition: {
|
||||
dns: ['8.8.8.8', '1.1.1.1'],
|
||||
},
|
||||
},
|
||||
{ appName: 'test' } as any,
|
||||
);
|
||||
@ -493,7 +517,9 @@ describe('compose/service', () => {
|
||||
appId: 1,
|
||||
serviceId: 1,
|
||||
serviceName: 'test',
|
||||
dns: ['1.1.1.1', '8.8.8.8'],
|
||||
composition: {
|
||||
dns: ['1.1.1.1', '8.8.8.8'],
|
||||
},
|
||||
},
|
||||
{ appName: 'test' } as any,
|
||||
);
|
||||
@ -506,7 +532,9 @@ describe('compose/service', () => {
|
||||
appId: 1,
|
||||
serviceId: 1,
|
||||
serviceName: 'test',
|
||||
volumes: ['abcdef', 'ghijk'],
|
||||
composition: {
|
||||
volumes: ['abcdef', 'ghijk'],
|
||||
},
|
||||
},
|
||||
{ appName: 'test' } as any,
|
||||
);
|
||||
@ -515,7 +543,9 @@ describe('compose/service', () => {
|
||||
appId: 1,
|
||||
serviceId: 1,
|
||||
serviceName: 'test',
|
||||
volumes: ['abcdef', 'ghijk'],
|
||||
composition: {
|
||||
volumes: ['abcdef', 'ghijk'],
|
||||
},
|
||||
},
|
||||
{ appName: 'test' } as any,
|
||||
);
|
||||
@ -526,7 +556,9 @@ describe('compose/service', () => {
|
||||
appId: 1,
|
||||
serviceId: 1,
|
||||
serviceName: 'test',
|
||||
volumes: ['ghijk', 'abcdef'],
|
||||
composition: {
|
||||
volumes: ['ghijk', 'abcdef'],
|
||||
},
|
||||
},
|
||||
{ appName: 'test' } as any,
|
||||
);
|
||||
@ -539,8 +571,10 @@ describe('compose/service', () => {
|
||||
appId: 1,
|
||||
serviceId: 1,
|
||||
serviceName: 'test',
|
||||
volumes: ['abcdef', 'ghijk'],
|
||||
dns: ['8.8.8.8', '1.1.1.1'],
|
||||
composition: {
|
||||
volumes: ['abcdef', 'ghijk'],
|
||||
dns: ['8.8.8.8', '1.1.1.1'],
|
||||
},
|
||||
},
|
||||
{ appName: 'test' } as any,
|
||||
);
|
||||
@ -549,8 +583,10 @@ describe('compose/service', () => {
|
||||
appId: 1,
|
||||
serviceId: 1,
|
||||
serviceName: 'test',
|
||||
volumes: ['ghijk', 'abcdef'],
|
||||
dns: ['8.8.8.8', '1.1.1.1'],
|
||||
composition: {
|
||||
volumes: ['ghijk', 'abcdef'],
|
||||
dns: ['8.8.8.8', '1.1.1.1'],
|
||||
},
|
||||
},
|
||||
{ appName: 'test' } as any,
|
||||
);
|
||||
@ -951,7 +987,9 @@ describe('compose/service', () => {
|
||||
releaseId: 2,
|
||||
serviceId: 3,
|
||||
imageId: 4,
|
||||
network_mode: 'service: test',
|
||||
composition: {
|
||||
network_mode: 'service: test',
|
||||
},
|
||||
},
|
||||
{ appName: 'test' } as any,
|
||||
);
|
||||
@ -965,8 +1003,10 @@ describe('compose/service', () => {
|
||||
releaseId: 2,
|
||||
serviceId: 3,
|
||||
imageId: 4,
|
||||
depends_on: ['another_service'],
|
||||
network_mode: 'service: test',
|
||||
composition: {
|
||||
depends_on: ['another_service'],
|
||||
network_mode: 'service: test',
|
||||
},
|
||||
},
|
||||
{ appName: 'test' } as any,
|
||||
);
|
||||
@ -982,7 +1022,9 @@ describe('compose/service', () => {
|
||||
releaseId: 2,
|
||||
serviceId: 3,
|
||||
imageId: 4,
|
||||
network_mode: 'service: test',
|
||||
composition: {
|
||||
network_mode: 'service: test',
|
||||
},
|
||||
},
|
||||
{ appName: 'test' } as any,
|
||||
);
|
||||
@ -1039,11 +1081,13 @@ describe('compose/service', () => {
|
||||
appId: 123,
|
||||
serviceId: 123,
|
||||
serviceName: 'test',
|
||||
securityOpt: [
|
||||
'label=user:USER',
|
||||
'label=user:ROLE',
|
||||
'seccomp=unconfined',
|
||||
],
|
||||
composition: {
|
||||
securityOpt: [
|
||||
'label=user:USER',
|
||||
'label=user:ROLE',
|
||||
'seccomp=unconfined',
|
||||
],
|
||||
},
|
||||
},
|
||||
{ appName: 'test' } as any,
|
||||
);
|
||||
|
@ -34,8 +34,11 @@ describe('compose/volume-manager', () => {
|
||||
}),
|
||||
createVolume({
|
||||
Name: Volume.generateDockerName(1, 'mysql'),
|
||||
// Recently created volumes contain io.balena.supervised label
|
||||
Labels: { 'io.balena.supervised': '1' },
|
||||
// Recently created volumes contain io.balena.supervised label and app-uuid
|
||||
Labels: {
|
||||
'io.balena.supervised': '1',
|
||||
'io.balena.app-uuid': 'deadbeef',
|
||||
},
|
||||
}),
|
||||
createVolume({
|
||||
Name: Volume.generateDockerName(1, 'backend'),
|
||||
@ -56,6 +59,7 @@ describe('compose/volume-manager', () => {
|
||||
await expect(volumeManager.getAll()).to.eventually.deep.equal([
|
||||
{
|
||||
appId: 1,
|
||||
appUuid: undefined,
|
||||
config: {
|
||||
driver: 'local',
|
||||
driverOpts: {},
|
||||
@ -67,17 +71,20 @@ describe('compose/volume-manager', () => {
|
||||
},
|
||||
{
|
||||
appId: 1,
|
||||
appUuid: 'deadbeef',
|
||||
config: {
|
||||
driver: 'local',
|
||||
driverOpts: {},
|
||||
labels: {
|
||||
'io.balena.supervised': '1',
|
||||
'io.balena.app-uuid': 'deadbeef',
|
||||
},
|
||||
},
|
||||
name: 'mysql',
|
||||
},
|
||||
{
|
||||
appId: 1,
|
||||
appUuid: undefined,
|
||||
config: {
|
||||
driver: 'local',
|
||||
driverOpts: {},
|
||||
@ -126,6 +133,7 @@ describe('compose/volume-manager', () => {
|
||||
).to.eventually.deep.equal([
|
||||
{
|
||||
appId: 111,
|
||||
appUuid: undefined,
|
||||
config: {
|
||||
driver: 'local',
|
||||
driverOpts: {},
|
||||
@ -152,7 +160,7 @@ describe('compose/volume-manager', () => {
|
||||
).to.be.rejected;
|
||||
|
||||
// Volume to create
|
||||
const volume = Volume.fromComposeObject('main', 111, {});
|
||||
const volume = Volume.fromComposeObject('main', 111, 'deadbeef', {});
|
||||
sinon.spy(volume, 'create');
|
||||
|
||||
// Create volume
|
||||
@ -177,7 +185,7 @@ describe('compose/volume-manager', () => {
|
||||
await withMockerode(
|
||||
async () => {
|
||||
// Create compose object for volume already set up in mock engine
|
||||
const volume = Volume.fromComposeObject('main', 111, {});
|
||||
const volume = Volume.fromComposeObject('main', 111, 'deadbeef', {});
|
||||
sinon.spy(volume, 'create');
|
||||
|
||||
// Create volume
|
||||
@ -206,7 +214,7 @@ describe('compose/volume-manager', () => {
|
||||
await withMockerode(
|
||||
async (mockerode) => {
|
||||
// Volume to remove
|
||||
const volume = Volume.fromComposeObject('main', 111, {});
|
||||
const volume = Volume.fromComposeObject('main', 111, 'deadbeef', {});
|
||||
sinon.spy(volume, 'remove');
|
||||
|
||||
// Remove volume
|
||||
@ -234,7 +242,7 @@ describe('compose/volume-manager', () => {
|
||||
await withMockerode(
|
||||
async (mockerode) => {
|
||||
// Volume to remove
|
||||
const volume = Volume.fromComposeObject('main', 111, {});
|
||||
const volume = Volume.fromComposeObject('main', 111, 'deadbeef', {});
|
||||
sinon.spy(volume, 'remove');
|
||||
|
||||
// Remove volume
|
||||
|
@ -9,28 +9,40 @@ import { createVolume, withMockerode } from '../../lib/mockerode';
|
||||
describe('compose/volume', () => {
|
||||
describe('creating a volume from a compose object', () => {
|
||||
it('should use proper defaults when no compose configuration is provided', () => {
|
||||
const volume = Volume.fromComposeObject('my_volume', 1234, {});
|
||||
const volume = Volume.fromComposeObject(
|
||||
'my_volume',
|
||||
1234,
|
||||
'deadbeef',
|
||||
{},
|
||||
);
|
||||
|
||||
expect(volume.name).to.equal('my_volume');
|
||||
expect(volume.appId).to.equal(1234);
|
||||
expect(volume.appUuid).to.equal('deadbeef');
|
||||
expect(volume.config).to.deep.equal({
|
||||
driver: 'local',
|
||||
driverOpts: {},
|
||||
labels: {
|
||||
'io.balena.supervised': 'true',
|
||||
'io.balena.app-uuid': 'deadbeef',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly parse compose volumes without an explicit driver', () => {
|
||||
const volume = Volume.fromComposeObject('one_volume', 1032480, {
|
||||
driver_opts: {
|
||||
opt1: 'test',
|
||||
const volume = Volume.fromComposeObject(
|
||||
'one_volume',
|
||||
1032480,
|
||||
'deadbeef',
|
||||
{
|
||||
driver_opts: {
|
||||
opt1: 'test',
|
||||
},
|
||||
labels: {
|
||||
'my-label': 'test-label',
|
||||
},
|
||||
},
|
||||
labels: {
|
||||
'my-label': 'test-label',
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
expect(volume).to.have.property('appId').that.equals(1032480);
|
||||
expect(volume).to.have.property('name').that.equals('one_volume');
|
||||
@ -39,6 +51,7 @@ describe('compose/volume', () => {
|
||||
.that.has.property('labels')
|
||||
.that.deep.equals({
|
||||
'io.balena.supervised': 'true',
|
||||
'io.balena.app-uuid': 'deadbeef',
|
||||
'my-label': 'test-label',
|
||||
});
|
||||
expect(volume)
|
||||
@ -54,15 +67,20 @@ describe('compose/volume', () => {
|
||||
});
|
||||
|
||||
it('should correctly parse compose volumes with an explicit driver', () => {
|
||||
const volume = Volume.fromComposeObject('one_volume', 1032480, {
|
||||
driver: 'other',
|
||||
driver_opts: {
|
||||
opt1: 'test',
|
||||
const volume = Volume.fromComposeObject(
|
||||
'one_volume',
|
||||
1032480,
|
||||
'deadbeef',
|
||||
{
|
||||
driver: 'other',
|
||||
driver_opts: {
|
||||
opt1: 'test',
|
||||
},
|
||||
labels: {
|
||||
'my-label': 'test-label',
|
||||
},
|
||||
},
|
||||
labels: {
|
||||
'my-label': 'test-label',
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
expect(volume).to.have.property('appId').that.equals(1032480);
|
||||
expect(volume).to.have.property('name').that.equals('one_volume');
|
||||
@ -71,6 +89,7 @@ describe('compose/volume', () => {
|
||||
.that.has.property('labels')
|
||||
.that.deep.equals({
|
||||
'io.balena.supervised': 'true',
|
||||
'io.balena.app-uuid': 'deadbeef',
|
||||
'my-label': 'test-label',
|
||||
});
|
||||
expect(volume)
|
||||
@ -119,6 +138,7 @@ describe('compose/volume', () => {
|
||||
Driver: 'local',
|
||||
Labels: {
|
||||
'io.balena.supervised': 'true',
|
||||
'io.balena.app-uuid': 'deadbeef',
|
||||
},
|
||||
Mountpoint: '/var/lib/docker/volumes/1032480_one_volume/_data',
|
||||
Name: '1032480_one_volume',
|
||||
@ -128,11 +148,13 @@ describe('compose/volume', () => {
|
||||
|
||||
expect(volume).to.have.property('appId').that.equals(1032480);
|
||||
expect(volume).to.have.property('name').that.equals('one_volume');
|
||||
expect(volume).to.have.property('appUuid').that.equals('deadbeef');
|
||||
expect(volume)
|
||||
.to.have.property('config')
|
||||
.that.has.property('labels')
|
||||
.that.deep.equals({
|
||||
'io.balena.supervised': 'true',
|
||||
'io.balena.app-uuid': 'deadbeef',
|
||||
});
|
||||
expect(volume)
|
||||
.to.have.property('config')
|
||||
@ -160,7 +182,11 @@ describe('compose/volume', () => {
|
||||
|
||||
it('should use defaults to create the volume when no options are given', async () => {
|
||||
await withMockerode(async (mockerode) => {
|
||||
const volume = Volume.fromComposeObject('one_volume', 1032480, {});
|
||||
const volume = Volume.fromComposeObject(
|
||||
'one_volume',
|
||||
1032480,
|
||||
'deadbeef',
|
||||
);
|
||||
|
||||
await volume.create();
|
||||
|
||||
@ -169,6 +195,7 @@ describe('compose/volume', () => {
|
||||
Driver: 'local',
|
||||
Labels: {
|
||||
'io.balena.supervised': 'true',
|
||||
'io.balena.app-uuid': 'deadbeef',
|
||||
},
|
||||
DriverOpts: {},
|
||||
});
|
||||
@ -177,14 +204,19 @@ describe('compose/volume', () => {
|
||||
|
||||
it('should pass configuration options to the engine', async () => {
|
||||
await withMockerode(async (mockerode) => {
|
||||
const volume = Volume.fromComposeObject('one_volume', 1032480, {
|
||||
driver_opts: {
|
||||
opt1: 'test',
|
||||
const volume = Volume.fromComposeObject(
|
||||
'one_volume',
|
||||
1032480,
|
||||
'deadbeef',
|
||||
{
|
||||
driver_opts: {
|
||||
opt1: 'test',
|
||||
},
|
||||
labels: {
|
||||
'my-label': 'test-label',
|
||||
},
|
||||
},
|
||||
labels: {
|
||||
'my-label': 'test-label',
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
await volume.create();
|
||||
|
||||
@ -194,6 +226,7 @@ describe('compose/volume', () => {
|
||||
Labels: {
|
||||
'my-label': 'test-label',
|
||||
'io.balena.supervised': 'true',
|
||||
'io.balena.app-uuid': 'deadbeef',
|
||||
},
|
||||
DriverOpts: {
|
||||
opt1: 'test',
|
||||
@ -208,7 +241,11 @@ describe('compose/volume', () => {
|
||||
|
||||
it('should log successful volume creation to the cloud', async () => {
|
||||
await withMockerode(async (mockerode) => {
|
||||
const volume = Volume.fromComposeObject('one_volume', 1032480, {});
|
||||
const volume = Volume.fromComposeObject(
|
||||
'one_volume',
|
||||
1032480,
|
||||
'deadbeef',
|
||||
);
|
||||
|
||||
await volume.create();
|
||||
|
||||
@ -223,8 +260,8 @@ describe('compose/volume', () => {
|
||||
describe('comparing volume configuration', () => {
|
||||
it('should ignore name and supervisor labels in the comparison', () => {
|
||||
expect(
|
||||
Volume.fromComposeObject('aaa', 1234, {}).isEqualConfig(
|
||||
Volume.fromComposeObject('bbb', 4567, {
|
||||
Volume.fromComposeObject('aaa', 1234, 'deadbeef').isEqualConfig(
|
||||
Volume.fromComposeObject('bbb', 4567, 'deadbeef', {
|
||||
driver: 'local',
|
||||
driver_opts: {},
|
||||
}),
|
||||
@ -232,13 +269,30 @@ describe('compose/volume', () => {
|
||||
).to.be.true;
|
||||
|
||||
expect(
|
||||
Volume.fromComposeObject('aaa', 1234, {}).isEqualConfig(
|
||||
Volume.fromComposeObject('bbb', 4567, {}),
|
||||
Volume.fromComposeObject('aaa', 1234, 'deadbeef').isEqualConfig(
|
||||
Volume.fromComposeObject('bbb', 4567, 'deadc0de'),
|
||||
),
|
||||
).to.be.true;
|
||||
|
||||
expect(
|
||||
Volume.fromComposeObject('aaa', 1234, {}).isEqualConfig(
|
||||
Volume.fromComposeObject('aaa', 1234, 'deadbeef').isEqualConfig(
|
||||
Volume.fromDockerVolume({
|
||||
Name: '1234_aaa',
|
||||
Driver: 'local',
|
||||
Labels: {
|
||||
'io.balena.supervised': 'true',
|
||||
'io.balena.app-uuid': 'deadbeef',
|
||||
},
|
||||
Options: {},
|
||||
Mountpoint: '/var/lib/docker/volumes/1032480_one_volume/_data',
|
||||
Scope: 'local',
|
||||
}),
|
||||
),
|
||||
).to.be.true;
|
||||
|
||||
// the app-uuid should be omitted from the comparison
|
||||
expect(
|
||||
Volume.fromComposeObject('aaa', 1234, 'deadbeef').isEqualConfig(
|
||||
Volume.fromDockerVolume({
|
||||
Name: '1234_aaa',
|
||||
Driver: 'local',
|
||||
@ -253,7 +307,7 @@ describe('compose/volume', () => {
|
||||
).to.be.true;
|
||||
|
||||
expect(
|
||||
Volume.fromComposeObject('aaa', 1234, {}).isEqualConfig(
|
||||
Volume.fromComposeObject('aaa', 1234, null as any).isEqualConfig(
|
||||
Volume.fromDockerVolume({
|
||||
Name: '4567_bbb',
|
||||
Driver: 'local',
|
||||
@ -268,13 +322,14 @@ describe('compose/volume', () => {
|
||||
).to.be.true;
|
||||
|
||||
expect(
|
||||
Volume.fromComposeObject('aaa', 1234, {}).isEqualConfig(
|
||||
Volume.fromComposeObject('aaa', 1234, 'deadbeef').isEqualConfig(
|
||||
Volume.fromDockerVolume({
|
||||
Name: '1234_aaa',
|
||||
Driver: 'local',
|
||||
Labels: {
|
||||
'some.other.label': '123',
|
||||
'io.balena.supervised': 'true',
|
||||
'io.balena.app-uuid': 'deadbeef',
|
||||
},
|
||||
Options: {},
|
||||
Mountpoint: '/var/lib/docker/volumes/1032480_one_volume/_data',
|
||||
@ -286,8 +341,8 @@ describe('compose/volume', () => {
|
||||
|
||||
it('should compare based on driver configuration and options', () => {
|
||||
expect(
|
||||
Volume.fromComposeObject('aaa', 1234, {}).isEqualConfig(
|
||||
Volume.fromComposeObject('aaa', 1234, {
|
||||
Volume.fromComposeObject('aaa', 1234, 'deadbeef').isEqualConfig(
|
||||
Volume.fromComposeObject('aaa', 1234, 'deadbeef', {
|
||||
driver: 'other',
|
||||
driver_opts: {},
|
||||
}),
|
||||
@ -295,10 +350,10 @@ describe('compose/volume', () => {
|
||||
).to.be.false;
|
||||
|
||||
expect(
|
||||
Volume.fromComposeObject('aaa', 1234, {
|
||||
Volume.fromComposeObject('aaa', 1234, 'deadbeef', {
|
||||
driver: 'other',
|
||||
}).isEqualConfig(
|
||||
Volume.fromComposeObject('aaa', 1234, {
|
||||
Volume.fromComposeObject('aaa', 1234, 'deadbeef', {
|
||||
driver: 'other',
|
||||
driver_opts: {},
|
||||
}),
|
||||
@ -306,15 +361,15 @@ describe('compose/volume', () => {
|
||||
).to.be.true;
|
||||
|
||||
expect(
|
||||
Volume.fromComposeObject('aaa', 1234, {}).isEqualConfig(
|
||||
Volume.fromComposeObject('aaa', 1234, {
|
||||
Volume.fromComposeObject('aaa', 1234, 'deadbeef', {}).isEqualConfig(
|
||||
Volume.fromComposeObject('aaa', 1234, 'deadbeef', {
|
||||
driver_opts: { opt: '123' },
|
||||
}),
|
||||
),
|
||||
).to.be.false;
|
||||
|
||||
expect(
|
||||
Volume.fromComposeObject('aaa', 1234, {
|
||||
Volume.fromComposeObject('aaa', 1234, 'deadbeef', {
|
||||
driver: 'other',
|
||||
labels: { 'some.other.label': '123' },
|
||||
driver_opts: { 'some-opt': '123' },
|
||||
@ -334,7 +389,7 @@ describe('compose/volume', () => {
|
||||
).to.be.false;
|
||||
|
||||
expect(
|
||||
Volume.fromComposeObject('aaa', 1234, {
|
||||
Volume.fromComposeObject('aaa', 1234, 'deadbeef', {
|
||||
driver: 'other',
|
||||
labels: { 'some.other.label': '123' },
|
||||
driver_opts: { 'some-opt': '123' },
|
||||
@ -375,7 +430,7 @@ describe('compose/volume', () => {
|
||||
|
||||
await withMockerode(
|
||||
async (mockerode) => {
|
||||
const volume = Volume.fromComposeObject('aaa', 1234, {});
|
||||
const volume = Volume.fromComposeObject('aaa', 1234, 'deadbeef');
|
||||
|
||||
// Check engine state before (this is really to test that mockerode is doing its job)
|
||||
expect((await mockerode.listVolumes()).Volumes).to.have.lengthOf(1);
|
||||
@ -402,7 +457,7 @@ describe('compose/volume', () => {
|
||||
|
||||
await withMockerode(
|
||||
async (mockerode) => {
|
||||
const volume = Volume.fromComposeObject('aaa', 1234, {});
|
||||
const volume = Volume.fromComposeObject('aaa', 1234, 'deadbeef');
|
||||
|
||||
// Check engine state before
|
||||
expect((await mockerode.listVolumes()).Volumes).to.have.lengthOf(1);
|
||||
@ -430,7 +485,7 @@ describe('compose/volume', () => {
|
||||
});
|
||||
await withMockerode(
|
||||
async (mockerode) => {
|
||||
const volume = Volume.fromComposeObject('aaa', 1234, {});
|
||||
const volume = Volume.fromComposeObject('aaa', 1234, 'deadbeef');
|
||||
|
||||
// Check engine state before
|
||||
expect((await mockerode.listVolumes()).Volumes).to.have.lengthOf(1);
|
||||
@ -456,7 +511,7 @@ describe('compose/volume', () => {
|
||||
});
|
||||
await withMockerode(
|
||||
async (mockerode) => {
|
||||
const volume = Volume.fromComposeObject('aaa', 1234, {});
|
||||
const volume = Volume.fromComposeObject('aaa', 1234, 'deadbeef');
|
||||
|
||||
// Stub the mockerode method to fail
|
||||
mockerode.removeVolume.rejects('Something bad happened');
|
||||
|
159
test/src/lib/json.spec.ts
Normal file
159
test/src/lib/json.spec.ts
Normal file
@ -0,0 +1,159 @@
|
||||
import { expect } from 'chai';
|
||||
import { equals, diff, prune, shallowDiff } from '../../../src/lib/json';
|
||||
|
||||
describe('JSON utils', () => {
|
||||
describe('equals', () => {
|
||||
it('should compare non-objects', () => {
|
||||
expect(equals(0, 1)).to.be.false;
|
||||
expect(equals(1111, 'a' as any)).to.be.false;
|
||||
expect(equals(1111, 2222)).to.be.false;
|
||||
expect(equals('aaa', 'bbb')).to.be.false;
|
||||
expect(equals('aaa', 'aaa')).to.be.true;
|
||||
expect(equals(null, null)).to.be.true;
|
||||
expect(equals(null, undefined)).to.be.false;
|
||||
expect(equals([], [])).to.be.true;
|
||||
expect(equals([1, 2, 3], [1, 2, 3])).to.be.true;
|
||||
expect(equals([1, 2, 3], [1, 2])).to.be.false;
|
||||
expect(equals([], []), 'empty arrays').to.be.true;
|
||||
});
|
||||
|
||||
it('should compare objects recursively', () => {
|
||||
expect(equals({}, {}), 'empty objects').to.be.true;
|
||||
expect(equals({ a: 1 }, { a: 1 }), 'single level objects').to.be.true;
|
||||
expect(equals({ a: 1 }, { a: 2 }), 'differing value single level objects')
|
||||
.to.be.false;
|
||||
expect(equals({ a: 1 }, { b: 1 }), 'differing keys single level objects');
|
||||
expect(
|
||||
equals({ a: 1 }, { b: 1, c: 2 }),
|
||||
'differing keys single level objects',
|
||||
).to.be.false;
|
||||
expect(equals({ a: { b: 1 } }, { a: { b: 1 } }), 'multiple level objects')
|
||||
.to.be.true;
|
||||
expect(
|
||||
equals({ a: { b: 1 } }, { a: { b: 1, c: 2 } }),
|
||||
'extra keys in multiple level objects',
|
||||
).to.be.false;
|
||||
expect(
|
||||
equals({ a: { b: 1 }, c: 2 }, { a: { b: 1 } }),
|
||||
'source object with extra keys',
|
||||
).to.be.false;
|
||||
expect(
|
||||
equals({ a: { b: 1 } }, { a: { b: 1 }, c: 2 }),
|
||||
'other object with extra keys',
|
||||
).to.be.false;
|
||||
expect(
|
||||
equals({ a: { b: 1 }, c: 2 }, { a: { b: 1 }, c: 2 }),
|
||||
'multiple level objects with extra keys',
|
||||
).to.be.true;
|
||||
expect(
|
||||
equals({ a: { b: 1 }, d: 2 }, { a: { b: 1 }, c: 2 }),
|
||||
'multiple level objects with same number of keys',
|
||||
).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('diff', () => {
|
||||
it('when comparing non-objects or arrays, always returns the target value', () => {
|
||||
expect(diff(1, 2)).to.equal(2);
|
||||
expect(diff(1, 'a' as any)).to.equal('a');
|
||||
expect(diff(1.1, 2)).to.equal(2);
|
||||
expect(diff('aaa', 'bbb')).to.equal('bbb');
|
||||
expect(diff({}, 'bbb' as any)).to.equal('bbb');
|
||||
expect(diff([1, 2, 3], [3, 4, 5])).to.deep.equal([3, 4, 5]);
|
||||
});
|
||||
|
||||
it('when comparing objects, calculates differences recursively', () => {
|
||||
// Reports all changes
|
||||
expect(diff({ a: 1 }, { b: 1 })).to.deep.equal({ a: undefined, b: 1 });
|
||||
|
||||
// Only reports array changes if arrays are different
|
||||
expect(diff({ a: [1, 2] }, { a: [1, 2] })).to.deep.equal({});
|
||||
|
||||
// Multiple key comparisons
|
||||
expect(diff({ a: 1, b: 1 }, { b: 2 })).to.deep.equal({
|
||||
a: undefined,
|
||||
b: 2,
|
||||
});
|
||||
|
||||
// Multiple target keys
|
||||
expect(diff({ a: 1 }, { b: 2, c: 1 })).to.deep.equal({
|
||||
a: undefined,
|
||||
b: 2,
|
||||
c: 1,
|
||||
});
|
||||
|
||||
// Removing a branch
|
||||
expect(diff({ a: 1, b: { c: 1 } }, { a: 1 })).to.deep.equal({
|
||||
b: undefined,
|
||||
});
|
||||
|
||||
// If the arrays are different, return target array
|
||||
expect(diff({ a: [1, 2] }, { a: [2, 3] })).to.deep.equal({ a: [2, 3] });
|
||||
|
||||
// Value to object
|
||||
expect(diff({ a: 1 }, { a: { c: 1 }, b: 2 })).to.deep.equal({
|
||||
a: { c: 1 },
|
||||
b: 2,
|
||||
});
|
||||
|
||||
// Changes in nested object
|
||||
expect(diff({ a: { c: 1 } }, { a: { c: 1, d: 2 }, b: 3 })).to.deep.equal({
|
||||
a: { d: 2 },
|
||||
b: 3,
|
||||
});
|
||||
|
||||
// Multiple level nested objects with value removal
|
||||
expect(
|
||||
diff({ a: { c: { f: 1, g: 2 } } }, { a: { c: { f: 2 }, d: 2 }, b: 3 }),
|
||||
).to.deep.equal({
|
||||
a: { c: { f: 2, g: undefined }, d: 2 },
|
||||
b: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('shallowDiff', () => {
|
||||
it('compares objects only to the given depth', () => {
|
||||
expect(shallowDiff({ a: 1 }, { a: 1 }, 0)).to.deep.equal({ a: 1 });
|
||||
expect(shallowDiff({ a: 1 }, { a: 1 }, 1)).to.deep.equal({});
|
||||
expect(shallowDiff({ a: 1 }, { a: 1 }, 2)).to.deep.equal({});
|
||||
expect(shallowDiff({ a: 1, b: 1 }, { a: 1 }, 1)).to.deep.equal({
|
||||
b: undefined,
|
||||
});
|
||||
expect(
|
||||
shallowDiff({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } }, 1),
|
||||
).to.deep.equal({});
|
||||
expect(
|
||||
shallowDiff({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2, d: 3 } }, 1),
|
||||
).to.deep.equal({ b: { c: 2, d: 3 } });
|
||||
expect(
|
||||
shallowDiff({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2, d: 3 } }, 2),
|
||||
).to.deep.equal({ b: { d: 3 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('prune', () => {
|
||||
it('does not remove empty arrays or other "empty values"', () => {
|
||||
expect(prune([])).to.deep.equal([]);
|
||||
expect(prune([0])).to.deep.equal([0]);
|
||||
expect(prune(0)).to.deep.equal(0);
|
||||
expect(prune({ a: 0 })).to.deep.equal({ a: 0 });
|
||||
expect(prune({ a: [] })).to.deep.equal({ a: [] });
|
||||
expect(prune({ a: [], b: 0 })).to.deep.equal({ a: [], b: 0 });
|
||||
});
|
||||
it('removes empty branches from a json object', () => {
|
||||
expect(prune({})).to.deep.equal({});
|
||||
expect(prune({ a: {} })).to.deep.equal({});
|
||||
expect(prune({ a: { b: {} } })).to.deep.equal({});
|
||||
expect(prune({ a: 1, b: {} })).to.deep.equal({ a: 1 });
|
||||
expect(prune({ a: 1, b: {}, c: { d: 2, e: {} } })).to.deep.equal({
|
||||
a: 1,
|
||||
c: { d: 2 },
|
||||
});
|
||||
expect(prune({ a: 1, b: {}, c: { d: 2, e: [] } })).to.deep.equal({
|
||||
a: 1,
|
||||
c: { d: 2, e: [] },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
265
test/src/lib/legacy.spec.ts
Normal file
265
test/src/lib/legacy.spec.ts
Normal file
@ -0,0 +1,265 @@
|
||||
import { expect } from 'chai';
|
||||
import { isRight } from 'fp-ts/lib/Either';
|
||||
import * as sinon from 'sinon';
|
||||
import * as nock from 'nock';
|
||||
|
||||
import { TargetState } from '../../../src/types';
|
||||
import * as config from '../../../src/config';
|
||||
import * as legacy from '../../../src/lib/legacy';
|
||||
import log from '../../../src/lib/supervisor-console';
|
||||
|
||||
describe('lib/legacy', () => {
|
||||
before(async () => {
|
||||
// disable log output during testing
|
||||
sinon.stub(log, 'debug');
|
||||
sinon.stub(log, 'warn');
|
||||
sinon.stub(log, 'info');
|
||||
sinon.stub(log, 'event');
|
||||
sinon.stub(log, 'success');
|
||||
|
||||
await config.initialized;
|
||||
|
||||
// Set the device uuid and name
|
||||
await config.set({ uuid: 'local' });
|
||||
await config.set({ name: 'my-device' });
|
||||
});
|
||||
|
||||
after(() => {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
describe('Converting target state v2 to v3', () => {
|
||||
it('accepts a local target state with empty configuration', async () => {
|
||||
const target = await legacy.fromV2TargetState({} as any, true);
|
||||
|
||||
const decoded = TargetState.decode(target);
|
||||
if (!isRight(decoded)) {
|
||||
console.log(decoded.left);
|
||||
// We do it this way let the type guard be triggered
|
||||
expect.fail('Resulting target state is a valid v3 target state');
|
||||
}
|
||||
|
||||
const decodedTarget = decoded.right;
|
||||
expect(decodedTarget)
|
||||
.to.have.property('local')
|
||||
.that.has.property('name')
|
||||
.that.equals('my-device');
|
||||
expect(decodedTarget)
|
||||
.to.have.property('local')
|
||||
.that.has.property('apps')
|
||||
.that.deep.equals({});
|
||||
expect(decodedTarget)
|
||||
.to.have.property('local')
|
||||
.that.has.property('config')
|
||||
.that.deep.equals({});
|
||||
});
|
||||
|
||||
it('accepts a local target state for an app without releases', async () => {
|
||||
const target = await legacy.fromV2TargetState(
|
||||
{
|
||||
local: {
|
||||
name: 'my-new-name',
|
||||
config: {
|
||||
BALENA_SUPERVISOR_PORT: '11111',
|
||||
},
|
||||
apps: {
|
||||
'1': {
|
||||
name: 'hello-world',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
true,
|
||||
);
|
||||
|
||||
const decoded = TargetState.decode(target);
|
||||
if (!isRight(decoded)) {
|
||||
console.log(decoded.left);
|
||||
// We do it this way let the type guard be triggered
|
||||
expect.fail('Resulting target state is a valid v3 target state');
|
||||
}
|
||||
|
||||
const decodedTarget = decoded.right;
|
||||
|
||||
expect(decodedTarget)
|
||||
.to.have.property('local')
|
||||
.that.has.property('config')
|
||||
.that.has.property('BALENA_SUPERVISOR_PORT')
|
||||
.that.equals('11111');
|
||||
expect(decodedTarget)
|
||||
.to.have.property('local')
|
||||
.that.has.property('name')
|
||||
.that.equals('my-new-name');
|
||||
expect(decodedTarget).to.have.property('local').that.has.property('apps');
|
||||
|
||||
const apps = decodedTarget.local.apps;
|
||||
|
||||
expect(apps)
|
||||
.to.have.property('1')
|
||||
.that.has.property('name')
|
||||
.that.equals('hello-world');
|
||||
expect(apps)
|
||||
.to.have.property('1')
|
||||
.that.has.property('releases')
|
||||
.that.deep.equals({});
|
||||
});
|
||||
|
||||
it('accepts a local target state with valid config and apps', async () => {
|
||||
const target = await legacy.fromV2TargetState(
|
||||
{
|
||||
local: {
|
||||
name: 'my-new-name',
|
||||
config: {
|
||||
BALENA_SUPERVISOR_PORT: '11111',
|
||||
},
|
||||
apps: {
|
||||
'1': {
|
||||
releaseId: 1,
|
||||
commit: 'localrelease',
|
||||
name: 'hello-world',
|
||||
services: {
|
||||
'1': {
|
||||
imageId: 1,
|
||||
serviceName: 'hello',
|
||||
image: 'ubuntu:latest',
|
||||
running: true,
|
||||
environment: {},
|
||||
labels: { 'io.balena.features.api': 'true' },
|
||||
privileged: true,
|
||||
ports: ['3001:3001'],
|
||||
},
|
||||
},
|
||||
networks: {
|
||||
my_net: {
|
||||
labels: {
|
||||
'io.balena.some.label': 'foo',
|
||||
},
|
||||
},
|
||||
},
|
||||
volumes: {
|
||||
my_volume: {
|
||||
labels: {
|
||||
'io.balena.some.label': 'foo',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
const decoded = TargetState.decode(target);
|
||||
if (!isRight(decoded)) {
|
||||
console.log(decoded.left);
|
||||
// We do it this way let the type guard be triggered
|
||||
expect.fail('Resulting target state is a valid v3 target state');
|
||||
}
|
||||
|
||||
const decodedTarget = decoded.right;
|
||||
|
||||
expect(decodedTarget)
|
||||
.to.have.property('local')
|
||||
.that.has.property('config')
|
||||
.that.has.property('BALENA_SUPERVISOR_PORT')
|
||||
.that.equals('11111');
|
||||
expect(decodedTarget)
|
||||
.to.have.property('local')
|
||||
.that.has.property('name')
|
||||
.that.equals('my-new-name');
|
||||
expect(decodedTarget).to.have.property('local').that.has.property('apps');
|
||||
|
||||
const apps = decodedTarget.local.apps;
|
||||
expect(apps)
|
||||
.to.have.property('1')
|
||||
.that.has.property('releases')
|
||||
.that.has.property('localrelease');
|
||||
expect(apps)
|
||||
.to.have.property('1')
|
||||
.that.has.property('name')
|
||||
.that.equals('hello-world');
|
||||
|
||||
const release = apps['1'].releases.localrelease;
|
||||
expect(release).to.have.property('id').that.equals(1);
|
||||
expect(release).to.have.property('services').that.has.property('hello');
|
||||
|
||||
const service = release.services.hello;
|
||||
expect(service).to.have.property('image').that.equals('ubuntu:latest');
|
||||
expect(service)
|
||||
.to.have.property('composition')
|
||||
.that.deep.equals({ privileged: true, ports: ['3001:3001'] });
|
||||
|
||||
expect(release)
|
||||
.to.have.property('networks')
|
||||
.that.has.property('my_net')
|
||||
.that.has.property('labels')
|
||||
.that.deep.equals({ 'io.balena.some.label': 'foo' });
|
||||
expect(release)
|
||||
.to.have.property('volumes')
|
||||
.that.has.property('my_volume')
|
||||
.that.has.property('labels')
|
||||
.that.deep.equals({ 'io.balena.some.label': 'foo' });
|
||||
});
|
||||
|
||||
it('accepts a cloud target state and requests app uuid from API', async () => {
|
||||
const apiEndpoint = await config.get('apiEndpoint');
|
||||
|
||||
nock(apiEndpoint)
|
||||
.get('/v6/application(1)?$select=uuid')
|
||||
.reply(200, { d: [{ uuid: 'some-uuid' }] });
|
||||
|
||||
const target = await legacy.fromV2TargetState(
|
||||
{
|
||||
local: {
|
||||
name: 'my-new-name',
|
||||
config: {
|
||||
BALENA_SUPERVISOR_PORT: '11111',
|
||||
},
|
||||
apps: {
|
||||
'1': {
|
||||
name: 'hello-world',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
false, // local = false
|
||||
);
|
||||
|
||||
const decoded = TargetState.decode(target);
|
||||
if (!isRight(decoded)) {
|
||||
console.log(decoded.left);
|
||||
// We do it this way let the type guard be triggered
|
||||
expect.fail('Resulting target state is a valid v3 target state');
|
||||
}
|
||||
|
||||
const decodedTarget = decoded.right;
|
||||
|
||||
expect(decodedTarget)
|
||||
.to.have.property('local')
|
||||
.that.has.property('config')
|
||||
.that.has.property('BALENA_SUPERVISOR_PORT')
|
||||
.that.equals('11111');
|
||||
expect(decodedTarget)
|
||||
.to.have.property('local')
|
||||
.that.has.property('name')
|
||||
.that.equals('my-new-name');
|
||||
expect(decodedTarget).to.have.property('local').that.has.property('apps');
|
||||
|
||||
const apps = decodedTarget.local.apps;
|
||||
|
||||
expect(apps)
|
||||
.to.have.property('some-uuid')
|
||||
.that.has.property('name')
|
||||
.that.equals('hello-world');
|
||||
expect(apps)
|
||||
.to.have.property('some-uuid')
|
||||
.that.has.property('id')
|
||||
.that.equals(1);
|
||||
expect(apps)
|
||||
.to.have.property('some-uuid')
|
||||
.that.has.property('releases')
|
||||
.that.deep.equals({});
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user