Merge pull request #1765 from balena-os/v3-target-state

Update supervisor to use new v3 target state format
This commit is contained in:
bulldozer-balena[bot] 2022-03-28 20:59:53 +00:00 committed by GitHub
commit a89b23ac7c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
62 changed files with 4544 additions and 2833 deletions

41
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: {},
},
},
},

View File

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

View File

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

View File

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

View File

@ -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: '[]',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": [

View File

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

View File

@ -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": [

View File

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

View File

@ -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": [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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