Merge pull request #2342 from balena-os/update-status

Report app release update status to the API
This commit is contained in:
flowzone-app[bot] 2024-09-02 15:32:39 +00:00 committed by GitHub
commit 23d74b7ca3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 774 additions and 469 deletions

8
package-lock.json generated
View File

@ -102,7 +102,7 @@
"ts-node": "^10.0.0", "ts-node": "^10.0.0",
"tsconfig-paths": "^4.1.0", "tsconfig-paths": "^4.1.0",
"typed-error": "^3.2.1", "typed-error": "^3.2.1",
"typescript": "^5.3.3", "typescript": "^5.5.4",
"webpack": "^5.74.0", "webpack": "^5.74.0",
"webpack-cli": "^5.0.0", "webpack-cli": "^5.0.0",
"winston": "^3.3.3", "winston": "^3.3.3",
@ -13520,9 +13520,9 @@
"dev": true "dev": true
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.4.5", "version": "5.5.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"dev": true, "dev": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",

View File

@ -128,7 +128,7 @@
"ts-node": "^10.0.0", "ts-node": "^10.0.0",
"tsconfig-paths": "^4.1.0", "tsconfig-paths": "^4.1.0",
"typed-error": "^3.2.1", "typed-error": "^3.2.1",
"typescript": "^5.3.3", "typescript": "^5.5.4",
"webpack": "^5.74.0", "webpack": "^5.74.0",
"webpack-cli": "^5.0.0", "webpack-cli": "^5.0.0",
"winston": "^3.3.3", "winston": "^3.3.3",

View File

@ -9,12 +9,11 @@ import * as deviceConfig from '../device-config';
import * as eventTracker from '../event-tracker'; import * as eventTracker from '../event-tracker';
import { loadBackupFromMigration } from '../lib/migration'; import { loadBackupFromMigration } from '../lib/migration';
import { InternalInconsistencyError, TargetStateError } from '../lib/errors';
import { import {
ContractValidationError, ContractValidationError,
ContractViolationError, ContractViolationError,
InternalInconsistencyError, } from '../lib/contracts';
TargetStateError,
} from '../lib/errors';
import log from '../lib/supervisor-console'; import log from '../lib/supervisor-console';

View File

@ -38,6 +38,7 @@ export interface AppConstructOpts {
commit?: string; commit?: string;
source?: string; source?: string;
isHost?: boolean; isHost?: boolean;
isRejected?: boolean;
services: Service[]; services: Service[];
volumes: Volume[]; volumes: Volume[];
@ -57,6 +58,7 @@ class AppImpl implements App {
public commit?: string; public commit?: string;
public source?: string; public source?: string;
public isHost?: boolean; public isHost?: boolean;
public isRejected?: boolean;
// Services are stored as an array, as at any one time we could have more than one // 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) // service for a single service ID running (for example handover)
public services: Service[]; public services: Service[];
@ -77,6 +79,10 @@ class AppImpl implements App {
this.networks = opts.networks; this.networks = opts.networks;
this.isHost = !!opts.isHost; this.isHost = !!opts.isHost;
if (isTargetState) {
this.isRejected = !!opts.isRejected;
}
if ( if (
this.networks.find((n) => n.name === 'default') == null && this.networks.find((n) => n.name === 'default') == null &&
isTargetState isTargetState
@ -1054,6 +1060,7 @@ class AppImpl implements App {
appName: app.name, appName: app.name,
source: app.source, source: app.source,
isHost: app.isHost, isHost: app.isHost,
isRejected: app.rejected,
services, services,
volumes, volumes,
networks, networks,

View File

@ -4,18 +4,14 @@ import type StrictEventEmitter from 'strict-event-emitter-types';
import * as config from '../config'; import * as config from '../config';
import type { Transaction } from '../db'; import type { Transaction } from '../db';
import { transaction } from '../db';
import * as logger from '../logger'; import * as logger from '../logger';
import LocalModeManager from '../local-mode'; import LocalModeManager from '../local-mode';
import * as dbFormat from '../device-state/db-format'; import * as dbFormat from '../device-state/db-format';
import { validateTargetContracts } from '../lib/contracts'; import * as contracts from '../lib/contracts';
import * as constants from '../lib/constants'; import * as constants from '../lib/constants';
import log from '../lib/supervisor-console'; import log from '../lib/supervisor-console';
import { import { InternalInconsistencyError } from '../lib/errors';
ContractViolationError,
InternalInconsistencyError,
} from '../lib/errors';
import { getServicesLockedByAppId, LocksTakenMap } from '../lib/update-lock'; import { getServicesLockedByAppId, LocksTakenMap } from '../lib/update-lock';
import { checkTruthy } from '../lib/validation'; import { checkTruthy } from '../lib/validation';
@ -195,8 +191,19 @@ export async function inferNextSteps(
// We want to remove images before moving on to anything else // We want to remove images before moving on to anything else
if (steps.length === 0) { if (steps.length === 0) {
const targetAndCurrent = _.intersection(currentAppIds, targetAppIds); // We only want to modify existing apps for accepted targets
const onlyTarget = _.difference(targetAppIds, currentAppIds); const acceptedTargetAppIds = targetAppIds.filter(
(id) => !targetApps[id].isRejected,
);
const targetAndCurrent = _.intersection(
currentAppIds,
acceptedTargetAppIds,
);
const onlyTarget = _.difference(acceptedTargetAppIds, currentAppIds);
// We do not want to remove rejected apps, so we compare with the
// original target id list
const onlyCurrent = _.difference(currentAppIds, targetAppIds); const onlyCurrent = _.difference(currentAppIds, targetAppIds);
// For apps that exist in both current and target state, calculate what we need to // For apps that exist in both current and target state, calculate what we need to
@ -503,87 +510,78 @@ export async function executeStep(
export async function setTarget( export async function setTarget(
apps: TargetApps, apps: TargetApps,
source: string, source: string,
maybeTrx?: Transaction, trx: Transaction,
) { ) {
const setInTransaction = async ( const setInTransaction = async (
$filteredApps: TargetApps, $apps: TargetApps,
trx: Transaction, $rejectedApps: string[],
$trx: Transaction,
) => { ) => {
await dbFormat.setApps($filteredApps, source, trx); await dbFormat.setApps($apps, source, $rejectedApps, $trx);
await trx('app') await $trx('app')
.where({ source }) .where({ source })
.whereNotIn( .whereNotIn(
'appId', 'appId',
// Use apps here, rather than filteredApps, to // Delete every appId not in the target list
// avoid removing a release from the database Object.values($apps).map(({ id: appId }) => appId),
// without an application to replace it.
// Currently this will only happen if the release
// which would replace it fails a contract
// validation check
Object.values(apps).map(({ id: appId }) => appId),
) )
.del(); .del();
}; };
// We look at the container contracts here, as if we // We look at the container contracts here, apps with failing contract requirements
// cannot run the release, we don't want it to be added // are stored in the database with a `rejected: true property`, which tells
// to the database, overwriting the current release. This // the inferNextSteps function to ignore them when making changes.
// is because if we just reject the release, but leave it //
// in the db, if for any reason the current state stops // Apps with optional services with unmet requirements are stored as
// running, we won't restart it, leaving the device // `rejected: false`, but services with unmet requirements are removed
// useless - The exception to this rule is when the only const contractViolators: contracts.ContractViolators = {};
// failing services are marked as optional, then we const fulfilledContracts = contracts.validateTargetContracts(apps);
// filter those out and add the target state to the database
const contractViolators: { [appName: string]: string[] } = {};
const fulfilledContracts = validateTargetContracts(apps);
const filteredApps = structuredClone(apps); const filteredApps = structuredClone(apps);
_.each( for (const [
fulfilledContracts, appUuid,
( { valid, unmetServices, unmetAndOptional },
{ valid, unmetServices, fulfilledServices, unmetAndOptional }, ] of Object.entries(fulfilledContracts)) {
appUuid, if (!valid) {
) => { // Add the app to the list of contract violators to generate a system
if (!valid) { // error
contractViolators[apps[appUuid].name] = unmetServices; contractViolators[appUuid] = {
return delete filteredApps[appUuid]; appId: apps[appUuid].id,
} else { appName: apps[appUuid].name,
// valid is true, but we could still be missing services: unmetServices.map(({ serviceName }) => serviceName),
// some optional containers, and need to filter };
// these out of the target state } else {
const [releaseUuid] = Object.keys(filteredApps[appUuid].releases); // App is valid, but we could still be missing
if (releaseUuid) { // some optional containers, and need to filter
const services = // these out of the target state
filteredApps[appUuid].releases[releaseUuid].services ?? {}; const app = filteredApps[appUuid];
filteredApps[appUuid].releases[releaseUuid].services = _.pick( for (const { commit, serviceName } of unmetAndOptional) {
services, delete app.releases[commit].services[serviceName];
Object.keys(services).filter((serviceName) =>
fulfilledServices.includes(serviceName),
),
);
}
if (unmetAndOptional.length !== 0) {
return reportOptionalContainers(unmetAndOptional);
}
} }
},
); if (unmetAndOptional.length !== 0) {
let promise; reportOptionalContainers(
if (maybeTrx != null) { unmetAndOptional.map(({ serviceName }) => serviceName),
promise = setInTransaction(filteredApps, maybeTrx); );
} else { }
promise = transaction((trx) => setInTransaction(filteredApps, trx)); }
} }
await promise;
let rejectedApps: string[] = [];
if (!_.isEmpty(contractViolators)) { if (!_.isEmpty(contractViolators)) {
throw new ContractViolationError(contractViolators); rejectedApps = Object.keys(contractViolators);
reportRejectedReleases(contractViolators);
} }
await setInTransaction(filteredApps, rejectedApps, trx);
} }
export async function getTargetApps(): Promise<TargetApps> { export async function getTargetApps(): Promise<TargetApps> {
return await dbFormat.getTargetJson(); return await dbFormat.getTargetJson();
} }
export async function getTargetAppsWithRejections() {
return await dbFormat.getTargetWithRejections();
}
/** /**
* This is only used by the API. Do not use as the use of serviceIds is getting * This is only used by the API. Do not use as the use of serviceIds is getting
* deprecated * deprecated
@ -788,12 +786,19 @@ function reportOptionalContainers(serviceNames: string[]) {
'. ', '. ',
)}`; )}`;
log.info(message); log.info(message);
return logger.logSystemMessage( logger.logSystemMessage(message, {});
message, }
{},
'optionalContainerViolation', function reportRejectedReleases(violators: contracts.ContractViolators) {
true, const appStrings = Object.values(violators).map(
({ appName, services }) =>
`${appName}: Services with unmet requirements: ${services.join(', ')}`,
); );
const message = `Some releases were rejected due to having unmet requirements:\n ${appStrings.join(
'\n ',
)}`;
log.error(message);
logger.logSystemMessage(message, { error: true });
} }
/** /**
@ -875,9 +880,9 @@ export async function getLegacyState() {
return { local: apps }; return { local: apps };
} }
// TODO: this function is probably more inefficient than it needs to be, since type AppsReport = { [uuid: string]: AppState };
// it tried to optimize for readability, look for a way to make it simpler
export async function getState() { export async function getState(): Promise<AppsReport> {
const [services, images] = await Promise.all([ const [services, images] = await Promise.all([
serviceManager.getState(), serviceManager.getState(),
imageManager.getState(), imageManager.getState(),
@ -990,7 +995,7 @@ export async function getState() {
); );
// Assemble the state of apps // Assemble the state of apps
const state: { [appUuid: string]: AppState } = {}; const state: AppsReport = {};
for (const { for (const {
appId, appId,
appUuid, appUuid,
@ -999,21 +1004,54 @@ export async function getState() {
createdAt, createdAt,
...svc ...svc
} of servicesToReport) { } of servicesToReport) {
state[appUuid] = { const app = state[appUuid] ?? {
...state[appUuid],
// Add the release_uuid if the commit has been stored in the database // Add the release_uuid if the commit has been stored in the database
...(commitsForApp[appId] && { release_uuid: commitsForApp[appId] }), ...(commitsForApp[appId] && { release_uuid: commitsForApp[appId] }),
releases: { releases: {},
...state[appUuid]?.releases,
[commit]: {
...state[appUuid]?.releases[commit],
services: {
...state[appUuid]?.releases[commit]?.services,
[serviceName]: svc,
},
},
},
}; };
const releases = app.releases;
releases[commit] = releases[commit] ?? {
update_status: 'done',
services: {},
};
releases[commit].services[serviceName] = svc;
// The update_status precedence order is as follows
// - aborted
// - downloading
// - downloaded
// - applying changes
// - done
if (svc.status === 'Aborted') {
releases[commit].update_status = 'aborted';
} else if (
releases[commit].update_status !== 'aborted' &&
svc.download_progress != null &&
svc.download_progress !== 100
) {
releases[commit].update_status = 'downloading';
} else if (
!['aborted', 'downloading'].includes(releases[commit].update_status!) &&
(svc.download_progress === 100 || svc.status === 'Downloaded')
) {
releases[commit].update_status = 'downloaded';
} else if (
// The `applying changes` state has lower precedence over the aborted/downloading/downloaded
// state
!['aborted', 'downloading', 'downloaded'].includes(
releases[commit].update_status!,
) &&
['installing', 'installed', 'awaiting handover'].includes(
svc.status.toLowerCase(),
)
) {
releases[commit].update_status = 'applying changes';
}
// Update the state object
state[appUuid] = app;
} }
return state; return state;
} }

View File

@ -21,6 +21,7 @@ export interface App {
commit?: string; commit?: string;
source?: string; source?: string;
isHost?: boolean; isHost?: boolean;
isRejected?: boolean;
// Services are stored as an array, as at any one time we could have more than one // 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) // service for a single service ID running (for example handover)
services: Service[]; services: Service[];

View File

@ -1,3 +1,5 @@
import type { App } from './app'; import type { App } from './app';
export type InstancedAppState = { [appId: number]: App }; export type InstancedAppState = { [appId: number]: App };
export type AppRelease = { appUuid: string; releaseUuid: string };

View File

@ -1,5 +1,3 @@
import _ from 'lodash';
import type * as db from '../db'; import type * as db from '../db';
import * as targetStateCache from './target-state-cache'; import * as targetStateCache from './target-state-cache';
import type { DatabaseApp, DatabaseService } from './target-state-cache'; import type { DatabaseApp, DatabaseService } from './target-state-cache';
@ -7,13 +5,8 @@ import type { DatabaseApp, DatabaseService } from './target-state-cache';
import { App } from '../compose/app'; import { App } from '../compose/app';
import * as images from '../compose/images'; import * as images from '../compose/images';
import type { import type { UUID, TargetApps, TargetRelease, TargetService } from '../types';
TargetApp, import type { InstancedAppState, AppRelease } from '../compose/types';
TargetApps,
TargetRelease,
TargetService,
} from '../types/state';
import type { InstancedAppState } from '../compose/types';
type InstancedApp = InstancedAppState[0]; type InstancedApp = InstancedAppState[0];
@ -40,16 +33,17 @@ export async function getApps(): Promise<InstancedAppState> {
export async function setApps( export async function setApps(
apps: TargetApps, apps: TargetApps,
source: string, source: string,
rejectedApps: UUID[] = [],
trx?: db.Transaction, trx?: db.Transaction,
) { ) {
const dbApps = Object.keys(apps).map((uuid) => { const dbApps = Object.keys(apps).map((uuid) => {
const { id: appId, ...app } = apps[uuid]; const { id: appId, ...app } = apps[uuid];
const rejected = rejectedApps.includes(uuid);
// Get the first uuid // Get the first uuid
const [releaseUuid] = Object.keys(app.releases); const releaseUuid = Object.keys(app.releases).shift();
const release = releaseUuid const release =
? app.releases[releaseUuid] releaseUuid != null ? app.releases[releaseUuid] : ({} as TargetRelease);
: ({} as TargetRelease);
const services = Object.keys(release.services ?? {}).map((serviceName) => { const services = Object.keys(release.services ?? {}).map((serviceName) => {
const { id: releaseId } = release; const { id: releaseId } = release;
@ -77,9 +71,13 @@ export async function setApps(
uuid, uuid,
source, source,
isHost: !!app.is_host, isHost: !!app.is_host,
rejected,
class: app.class, class: app.class,
name: app.name, name: app.name,
...(releaseUuid && { releaseId: release.id, commit: releaseUuid }), ...(releaseUuid && {
releaseId: release.id,
commit: releaseUuid,
}),
services: JSON.stringify(services), services: JSON.stringify(services),
networks: JSON.stringify(release.networks ?? {}), networks: JSON.stringify(release.networks ?? {}),
volumes: JSON.stringify(release.volumes ?? {}), volumes: JSON.stringify(release.volumes ?? {}),
@ -92,67 +90,78 @@ export async function setApps(
/** /**
* Create target state from database state * Create target state from database state
*/ */
export async function getTargetJson(): Promise<TargetApps> { export async function getTargetWithRejections(): Promise<{
apps: TargetApps;
rejections: AppRelease[];
}> {
const dbApps = await getDBEntry(); const dbApps = await getDBEntry();
return dbApps const apps: TargetApps = {};
.map( const rejections: AppRelease[] = [];
({
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,
}),
{},
);
const releases = releaseUuid for (const {
? { source,
[releaseUuid]: { rejected,
id: releaseId, uuid,
services, releaseId,
networks: JSON.parse(app.networks), commit: releaseUuid,
volumes: JSON.parse(app.volumes), ...app
} as TargetRelease, } of dbApps) {
} const services = Object.fromEntries(
: {}; (JSON.parse(app.services) as DatabaseService[]).map(
({
return [ serviceName,
uuid, serviceId,
imageId,
// Ignore these fields
appId: _appId,
appUuid: _appUuid,
commit: _commit,
releaseId: _releaseId,
// Use the remainder of the fields for the service description
...service
}) => [
serviceName,
{ {
id: app.appId, id: serviceId,
name: app.name, image_id: imageId,
class: app.class, ...service,
is_host: !!app.isHost, } satisfies TargetService,
releases, ],
}, ),
]; );
},
) const releases =
.reduce((apps, [uuid, app]) => ({ ...apps, [uuid]: app }), {}); releaseUuid && releaseId
? {
[releaseUuid]: {
id: releaseId,
services,
networks: JSON.parse(app.networks),
volumes: JSON.parse(app.volumes),
} satisfies TargetRelease,
}
: {};
if (rejected && releaseUuid) {
rejections.push({ appUuid: uuid, releaseUuid });
}
apps[uuid] = {
id: app.appId,
name: app.name,
class: app.class,
is_host: !!app.isHost,
releases,
};
}
return { apps, rejections };
}
export async function getTargetJson(): Promise<TargetApps> {
const { apps } = await getTargetWithRejections();
return apps;
} }
function getDBEntry(): Promise<DatabaseApp[]>; function getDBEntry(): Promise<DatabaseApp[]>;

View File

@ -26,12 +26,7 @@ import type { InstancedDeviceState } from './target-state';
import * as TargetState from './target-state'; import * as TargetState from './target-state';
export { getTarget, setTarget } from './target-state'; export { getTarget, setTarget } from './target-state';
import type { import type { DeviceLegacyState, DeviceState, DeviceReport } from '../types';
DeviceLegacyState,
DeviceState,
DeviceReport,
AppState,
} from '../types';
import type { import type {
CompositionStepT, CompositionStepT,
CompositionStepAction, CompositionStepAction,
@ -366,18 +361,34 @@ export async function getCurrentForReport(
): Promise<DeviceState> { ): Promise<DeviceState> {
const apps = await applicationManager.getState(); const apps = await applicationManager.getState();
const { apps: targetApps, rejections } =
await applicationManager.getTargetAppsWithRejections();
const targetAppUuids = Object.keys(targetApps);
// Fiter current apps by the target state as the supervisor cannot // Fiter current apps by the target state as the supervisor cannot
// report on apps for which it doesn't have API permissions // report on apps for which it doesn't have API permissions
const targetAppUuids = Object.keys(await applicationManager.getTargetApps()); // this step also adds rejected commits for the report
const appsForReport = Object.keys(apps) const appsForReport = Object.fromEntries(
.filter((appUuid) => targetAppUuids.includes(appUuid)) Object.entries(apps).flatMap(([appUuid, app]) => {
.reduce( if (!targetAppUuids.includes(appUuid)) {
(filteredApps, appUuid) => ({ return [];
...filteredApps, }
[appUuid]: apps[appUuid],
}), for (const r of rejections) {
{} as { [appUuid: string]: AppState }, if (r.appUuid !== appUuid) {
); continue;
}
// Add the rejected release to apps for report
app.releases[r.releaseUuid] = {
update_status: 'rejected',
services: {},
};
}
return [[appUuid, app]];
}),
);
const { uuid, localMode } = await config.getMany(['uuid', 'localMode']); const { uuid, localMode } = await config.getMany(['uuid', 'localMode']);

View File

@ -8,6 +8,8 @@ import type { TargetAppClass } from '../types';
// at all, and we can use the below type for both insertion and retrieval. // at all, and we can use the below type for both insertion and retrieval.
export interface DatabaseApp { export interface DatabaseApp {
name: string; name: string;
// releaseId and commit may be empty as the device could
// have been moved to an app without any releases
/** /**
* @deprecated to be removed in target state v4 * @deprecated to be removed in target state v4
*/ */
@ -24,6 +26,7 @@ export interface DatabaseApp {
source: string; source: string;
class: TargetAppClass; class: TargetAppClass;
isHost: boolean; isHost: boolean;
rejected: boolean;
} }
export type DatabaseService = { export type DatabaseService = {

View File

@ -2,23 +2,66 @@ import { isLeft } from 'fp-ts/lib/Either';
import * as t from 'io-ts'; import * as t from 'io-ts';
import Reporter from 'io-ts-reporters'; import Reporter from 'io-ts-reporters';
import _ from 'lodash'; import _ from 'lodash';
import { TypedError } from 'typed-error';
import type { ContractObject } from '@balena/contrato'; import type { ContractObject } from '@balena/contrato';
import { Blueprint, Contract } from '@balena/contrato'; import { Blueprint, Contract } from '@balena/contrato';
import { ContractValidationError, InternalInconsistencyError } from './errors'; import { InternalInconsistencyError } from './errors';
import { checkTruthy } from './validation'; import { checkTruthy } from './validation';
import type { TargetApps } from '../types'; import type { TargetApps } from '../types';
export interface ApplicationContractResult { /**
valid: boolean; * This error is thrown when a container contract does not
unmetServices: string[]; * match the minimum we expect from it
fulfilledServices: string[]; */
unmetAndOptional: string[]; export class ContractValidationError extends TypedError {
constructor(serviceName: string, error: string) {
super(
`The contract for service ${serviceName} failed validation, with error: ${error}`,
);
}
} }
export interface ServiceContracts { export interface ContractViolators {
[serviceName: string]: { contract?: ContractObject; optional: boolean }; [appUuid: string]: { appName: string; appId: number; services: string[] };
}
/**
* This error is thrown when one or releases cannot be ran
* as one or more of their container have unmet requirements.
* It accepts a map of app names to arrays of service names
* which have unmet requirements.
*/
export class ContractViolationError extends TypedError {
constructor(violators: ContractViolators) {
const appStrings = Object.values(violators).map(
({ appName, services }) =>
`${appName}: Services with unmet requirements: ${services.join(', ')}`,
);
super(
`Some releases were rejected due to having unmet requirements:\n ${appStrings.join(
'\n ',
)}`,
);
}
}
export interface ServiceCtx {
serviceName: string;
commit: string;
}
export interface AppContractResult {
valid: boolean;
unmetServices: ServiceCtx[];
fulfilledServices: ServiceCtx[];
unmetAndOptional: ServiceCtx[];
}
interface ServiceWithContract extends ServiceCtx {
contract?: ContractObject;
optional: boolean;
} }
type PotentialContractRequirements = type PotentialContractRequirements =
@ -52,12 +95,15 @@ function isValidRequirementType(
} }
export function containerContractsFulfilled( export function containerContractsFulfilled(
serviceContracts: ServiceContracts, servicesWithContract: ServiceWithContract[],
): ApplicationContractResult { ): AppContractResult {
const containers = _(serviceContracts).map('contract').compact().value(); const containers = servicesWithContract
.map(({ contract }) => contract)
.filter((c) => c != null) satisfies ContractObject[];
const contractTypes = Object.keys(contractRequirementVersions);
const blueprintMembership: Dictionary<number> = {}; const blueprintMembership: Dictionary<number> = {};
for (const component of _.keys(contractRequirementVersions)) { for (const component of contractTypes) {
blueprintMembership[component] = 1; blueprintMembership[component] = 1;
} }
const blueprint = new Blueprint( const blueprint = new Blueprint(
@ -89,10 +135,11 @@ export function containerContractsFulfilled(
'More than one solution available for container contracts when only one is expected!', 'More than one solution available for container contracts when only one is expected!',
); );
} }
if (solution.length === 0) { if (solution.length === 0) {
return { return {
valid: false, valid: false,
unmetServices: _.keys(serviceContracts), unmetServices: servicesWithContract,
fulfilledServices: [], fulfilledServices: [],
unmetAndOptional: [], unmetAndOptional: [],
}; };
@ -108,7 +155,7 @@ export function containerContractsFulfilled(
return { return {
valid: true, valid: true,
unmetServices: [], unmetServices: [],
fulfilledServices: _.keys(serviceContracts), fulfilledServices: servicesWithContract,
unmetAndOptional: [], unmetAndOptional: [],
}; };
} else { } else {
@ -117,16 +164,14 @@ export function containerContractsFulfilled(
// those containers whose contract was not met are // those containers whose contract was not met are
// marked as optional, the target state is still valid, // marked as optional, the target state is still valid,
// but we ignore the optional containers // but we ignore the optional containers
const [fulfilledServices, unfulfilledServices] = _.partition( const [fulfilledServices, unfulfilledServices] = _.partition(
_.keys(serviceContracts), servicesWithContract,
(serviceName) => { ({ contract }) => {
const { contract } = serviceContracts[serviceName];
if (!contract) { if (!contract) {
return true; return true;
} }
// Did we find the contract in the generated state? // Did we find the contract in the generated state?
return _.some(children, (child) => return children.some((child) =>
_.isEqual((child as any).raw, contract), _.isEqual((child as any).raw, contract),
); );
}, },
@ -134,9 +179,7 @@ export function containerContractsFulfilled(
const [unmetAndRequired, unmetAndOptional] = _.partition( const [unmetAndRequired, unmetAndOptional] = _.partition(
unfulfilledServices, unfulfilledServices,
(serviceName) => { ({ optional }) => !optional,
return !serviceContracts[serviceName].optional;
},
); );
return { return {
@ -198,67 +241,43 @@ export function validateContract(contract: unknown): boolean {
return true; return true;
} }
export function validateTargetContracts( export function validateTargetContracts(
apps: TargetApps, apps: TargetApps,
): Dictionary<ApplicationContractResult> { ): Dictionary<AppContractResult> {
return Object.keys(apps) const result: Dictionary<AppContractResult> = {};
.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);
return { for (const [appUuid, app] of Object.entries(apps)) {
serviceName, const releases = Object.entries(app.releases);
contract, if (releases.length === 0) {
optional: checkTruthy( continue;
service.labels?.['io.balena.features.optional'], }
),
}; // While app.releases is an object, we expect a target to only
} catch (e: any) { // contain a single release per app so we use just the first element
throw new ContractValidationError(serviceName, e.message); const [commit, release] = releases[0];
}
const servicesWithContract = Object.entries(release.services ?? {}).map(
([serviceName, { contract, labels = {} }]) => {
if (contract) {
try {
validateContract(contract);
} catch (e: any) {
throw new ContractValidationError(serviceName, e.message);
} }
}
// Return a default contract for the service if no contract is defined return {
return { serviceName, contract: undefined, optional: false }; serviceName,
}) commit,
// map by serviceName contract,
.reduce( optional: checkTruthy(labels['io.balena.features.optional']),
(contracts, { serviceName, ...serviceContract }) => ({ };
...contracts, },
[serviceName]: serviceContract,
}),
{} as ServiceContracts,
);
if (Object.keys(serviceContracts).length > 0) {
// Validate service contracts if any
return [appUuid, containerContractsFulfilled(serviceContracts)];
}
// Return success if no services are found
return [
appUuid,
{
valid: true,
fulfilledServices: Object.keys(release?.services ?? []),
unmetAndOptional: [],
unmetServices: [],
},
];
})
.reduce(
(result, [appUuid, contractFulfilled]) => ({
...result,
[appUuid]: contractFulfilled,
}),
{} as Dictionary<ApplicationContractResult>,
); );
result[appUuid] = containerContractsFulfilled(servicesWithContract);
}
return result;
} }

View File

@ -1,4 +1,4 @@
import { endsWith, map } from 'lodash'; import { endsWith } from 'lodash';
import { TypedError } from 'typed-error'; import { TypedError } from 'typed-error';
import { checkInt } from './validation'; import { checkInt } from './validation';
@ -104,39 +104,6 @@ export class TargetStateError extends TypedError {}
*/ */
export class SupervisorContainerNotFoundError extends TypedError {} export class SupervisorContainerNotFoundError extends TypedError {}
/**
* This error is thrown when a container contract does not
* match the minimum we expect from it
*/
export class ContractValidationError extends TypedError {
constructor(serviceName: string, error: string) {
super(
`The contract for service ${serviceName} failed validation, with error: ${error}`,
);
}
}
/**
* This error is thrown when one or releases cannot be ran
* as one or more of their container have unmet requirements.
* It accepts a map of app names to arrays of service names
* which have unmet requirements.
*/
export class ContractViolationError extends TypedError {
constructor(violators: { [appName: string]: string[] }) {
const appStrings = map(
violators,
(svcs, name) =>
`${name}: Services with unmet requirements: ${svcs.join(', ')}`,
);
super(
`Some releases were rejected due to having unmet requirements:\n ${appStrings.join(
'\n ',
)}`,
);
}
}
export class AppsJsonParseError extends TypedError {} export class AppsJsonParseError extends TypedError {}
export class DatabaseParseError extends TypedError {} export class DatabaseParseError extends TypedError {}
export class BackupError extends TypedError {} export class BackupError extends TypedError {}

10
src/migrations/M00012.js Normal file
View File

@ -0,0 +1,10 @@
export async function up(knex) {
// Add a `rejected` field to the target app
await knex.schema.table('app', (table) => {
table.boolean('rejected').defaultTo(false);
});
}
export function down() {
throw new Error('Not implemented');
}

View File

@ -127,10 +127,22 @@ const fromType = <T extends object>(name: string) =>
// Alias short string to UUID so code reads more clearly // Alias short string to UUID so code reads more clearly
export const UUID = ShortString; export const UUID = ShortString;
export type UUID = t.TypeOf<typeof UUID>;
/** *************** /** ***************
* Current state * * Current state *
*****************/ *****************/
const UpdateStatus = t.union([
t.literal('rejected'),
t.literal('downloading'),
t.literal('downloaded'),
t.literal('applying changes'),
t.literal('aborted'),
t.literal('done'),
]);
export type UpdateStatus = t.TypeOf<typeof UpdateStatus>;
const ServiceState = t.intersection([ const ServiceState = t.intersection([
t.type({ t.type({
image: t.string, image: t.string,
@ -143,6 +155,7 @@ const ServiceState = t.intersection([
export type ServiceState = t.TypeOf<typeof ServiceState>; export type ServiceState = t.TypeOf<typeof ServiceState>;
const ReleaseState = t.type({ const ReleaseState = t.type({
update_status: UpdateStatus,
services: t.record(DockerName, ServiceState), services: t.record(DockerName, ServiceState),
}); });
export type ReleaseState = t.TypeOf<typeof ReleaseState>; export type ReleaseState = t.TypeOf<typeof ReleaseState>;
@ -182,6 +195,8 @@ const DeviceReport = t.partial({
cpu_usage: t.number, cpu_usage: t.number,
cpu_id: t.string, cpu_id: t.string,
is_undervolted: t.boolean, is_undervolted: t.boolean,
// These are for internal reporting only, they are not sent
// to the API
update_failed: t.boolean, update_failed: t.boolean,
update_pending: t.boolean, update_pending: t.boolean,
update_downloaded: t.boolean, update_downloaded: t.boolean,

View File

@ -1595,6 +1595,97 @@ describe('compose/application-manager', () => {
.that.deep.includes({ name: 'main-image' }); .that.deep.includes({ name: 'main-image' });
}); });
it('should not calculate steps for a rejected app', async () => {
const targetApps = createApps(
{
services: [
await createService({
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, 'app-one', {}),
Network.fromComposeObject('default', 2, 'app-two', {}),
],
rejectedAppIds: [1],
},
true,
);
const { currentApps, availableImages, downloading, containerIdsByAppId } =
createCurrentState({
services: [
await createService({
running: true,
image: 'old-image-1',
appId: 1,
appUuid: 'app-one',
commit: 'commit-for-app-0',
}),
],
networks: [
// Default networks for two apps
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',
}),
],
});
const steps = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
downloading,
availableImages,
containerIdsByAppId,
// Mock locks taken to avoid takeLock step
locksTaken: new LocksTakenMap([{ appId: 2, services: ['main'] }]),
},
);
// Expect a start step for both apps
expect(
steps.filter((s: any) => s.target && s.target.appId === 1),
).to.have.lengthOf(0);
expect(
steps.filter((s: any) => s.image && s.image.appId === 1),
).to.have.lengthOf(0);
expect(
steps.filter(
(s: any) =>
s.action === 'start' &&
s.target.appId === 2 &&
s.target.serviceName === 'main',
),
).to.have.lengthOf(1);
});
it('should correctly generate steps for multiple apps', async () => { it('should correctly generate steps for multiple apps', async () => {
const targetApps = createApps( const targetApps = createApps(
{ {
@ -2444,6 +2535,7 @@ describe('compose/application-manager', () => {
download_progress: 50, download_progress: 50,
}, },
}, },
update_status: 'downloading',
}, },
}, },
}, },
@ -2456,6 +2548,7 @@ describe('compose/application-manager', () => {
status: 'Downloaded', status: 'Downloaded',
}, },
}, },
update_status: 'downloaded',
}, },
newrelease: { newrelease: {
services: { services: {
@ -2465,6 +2558,7 @@ describe('compose/application-manager', () => {
download_progress: 75, download_progress: 75,
}, },
}, },
update_status: 'downloading',
}, },
}, },
}, },
@ -2548,6 +2642,7 @@ describe('compose/application-manager', () => {
download_progress: 0, download_progress: 0,
}, },
}, },
update_status: 'downloading',
}, },
}, },
}, },
@ -2560,6 +2655,81 @@ describe('compose/application-manager', () => {
status: 'exited', status: 'exited',
}, },
}, },
update_status: 'done',
},
},
},
});
});
it('reports aborted state if one of the services/images status is aborted', async () => {
getImagesState.resolves([
{
name: 'ubuntu:latest',
commit: 'latestrelease',
appUuid: 'myapp',
serviceName: 'ubuntu',
status: 'Downloaded',
},
{
name: 'node:latest',
commit: 'latestrelease',
appUuid: 'myapp',
serviceName: 'node',
status: 'Downloaded',
downloadProgress: 100,
},
{
name: 'alpine:latest',
commit: 'latestrelease',
appUuid: 'myapp',
serviceName: 'alpine',
status: 'Aborted',
downloadProgress: 0,
},
]);
getServicesState.resolves([
{
commit: 'latestrelease',
appUuid: 'myapp',
serviceName: 'ubuntu',
status: 'Running',
createdAt: new Date('2021-09-01T13:00:00'),
},
{
appUuid: 'myapp',
commit: 'latestrelease',
serviceName: 'node',
// we don't have a way to abort a failing service install yet, but
// once we do it will need to use the status field
status: 'Aborted',
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',
// we don't have a way to abort a failing download yet, but
// once we do it will need to use the status field
status: 'Aborted',
download_progress: 0,
},
node: {
image: 'node:latest',
status: 'Aborted',
download_progress: 100,
},
},
update_status: 'aborted',
}, },
}, },
}, },
@ -2610,6 +2780,7 @@ describe('compose/application-manager', () => {
status: 'Awaiting handover', status: 'Awaiting handover',
}, },
}, },
update_status: 'applying changes',
}, },
oldrelease: { oldrelease: {
services: { services: {
@ -2618,6 +2789,7 @@ describe('compose/application-manager', () => {
status: 'Handing over', status: 'Handing over',
}, },
}, },
update_status: 'done',
}, },
}, },
}, },

View File

@ -368,6 +368,7 @@ describe('device-state', () => {
const app = targetState.local.apps[1234]; const app = targetState.local.apps[1234];
expect(app).to.have.property('appName').that.equals('superapp'); expect(app).to.have.property('appName').that.equals('superapp');
expect(app).to.have.property('commit').that.equals('one'); expect(app).to.have.property('commit').that.equals('one');
expect(app).to.have.property('isRejected').that.is.false;
// Only a single service should be on the target state // 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).to.have.property('services').that.is.an('array').with.length(1);
expect(app.services[0]) expect(app.services[0])
@ -375,7 +376,8 @@ describe('device-state', () => {
.that.equals('valid'); .that.equals('valid');
}); });
it('rejects a target state with invalid contract and non optional service', async () => { it('accepts target state with invalid contract setting isRejected to true and resets state when a valid target is received', async () => {
// Set the rejected target
await expect( await expect(
deviceState.setTarget({ deviceState.setTarget({
local: { local: {
@ -424,7 +426,66 @@ describe('device-state', () => {
}, },
}, },
} as TargetState), } as TargetState),
).to.be.rejected; ).to.not.be.rejected;
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');
expect(app).to.have.property('isRejected').that.is.true;
// Now set a good target for the same app
await deviceState.setTarget({
local: {
name: 'aDeviceWithDifferentName',
config: {},
apps: {
myapp: {
id: 1234,
name: 'superapp',
class: 'fleet',
releases: {
two: {
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 targetState2 = await deviceState.getTarget();
const app2 = targetState2.local.apps[1234];
expect(app2).to.have.property('commit').that.equals('two');
expect(app2).to.have.property('isRejected').that.is.false;
}); });
// TODO: There is no easy way to test this behaviour with the current // TODO: There is no easy way to test this behaviour with the current

View File

@ -1,84 +0,0 @@
import * as constants from '~/lib/constants';
import * as db from '~/src/db';
import * as sinon from 'sinon';
// Creates a test database and returns a query builder
export async function createDB() {
const oldDatabasePath = process.env.DATABASE_PATH;
// for testing we use an in memory database
process.env.DATABASE_PATH = ':memory:';
// @ts-expect-error need to rewrite the value of databasePath as that
// is used directly by the db module
constants.databasePath = process.env.DATABASE_PATH;
// Cleanup the module cache in order to have it reloaded in the local context
delete require.cache[require.resolve('~/src/db')];
// Initialize the database module
await db.initialized();
// Get the knex instance to allow queries to the db
const { models, upsertModel } = db;
// This is hacky but haven't found another way to do it,
// stubbing the db methods here ensures the module under test
// is using the database we want
sinon.stub(db, 'models').callsFake(models);
sinon.stub(db, 'upsertModel').callsFake(upsertModel);
return {
// Returns a query builder instance for the given
// table in order perform data operations
models,
// Resets the database to initial value post
// migrations
async reset() {
// Reset the contents of the db
await db.transaction(async (trx: any) => {
const result = await trx.raw(`
SELECT name, sql
FROM sqlite_master
WHERE type='table'`);
for (const r of result) {
// We don't run the migrations again
if (r.name !== 'knex_migrations') {
await trx.raw(`DELETE FROM ${r.name}`);
}
}
// The supervisor expects this value to already have
// been pre-populated
await trx('deviceConfig').insert({ targetValues: '{}' });
});
// Reset stub call history
(db.models as sinon.SinonStub).resetHistory();
(db.upsertModel as sinon.SinonStub).resetHistory();
},
// Destroys the in-memory database and resets environment
async destroy() {
// Remove data from the in memory database just in case
await this.reset();
// Restore the old datbase path
process.env.DATABASE_PATH = oldDatabasePath;
// Restore stubs
(db.models as sinon.SinonStub).restore();
(db.upsertModel as sinon.SinonStub).restore();
// @ts-expect-error restore the constant default
constants.databasePath = process.env.DATABASE_PATH;
// Cleanup the module cache in order to have it reloaded
// correctly next time it's used
delete require.cache[require.resolve('~/src/db')];
},
};
}
export type TestDatabase = UnwrappedPromise<ReturnType<typeof createDB>>;

View File

@ -85,6 +85,7 @@ export function createApp({
isTarget = false, isTarget = false,
appId = 1, appId = 1,
appUuid = 'appuuid', appUuid = 'appuuid',
isRejected = false,
} = {}) { } = {}) {
return new App( return new App(
{ {
@ -93,6 +94,7 @@ export function createApp({
services, services,
networks, networks,
volumes, volumes,
isRejected,
}, },
isTarget, isTarget,
); );
@ -103,6 +105,7 @@ export function createApps(
services = [] as Service[], services = [] as Service[],
networks = [] as Network[], networks = [] as Network[],
volumes = [] as Volume[], volumes = [] as Volume[],
rejectedAppIds = [] as number[],
}, },
target = false, target = false,
) { ) {
@ -129,6 +132,7 @@ export function createApps(
const apps: InstancedAppState = {}; const apps: InstancedAppState = {};
for (const appId of allAppIds) { for (const appId of allAppIds) {
const isRejected = rejectedAppIds.includes(appId);
apps[appId] = createApp({ apps[appId] = createApp({
services: servicesByAppId[appId] ?? [], services: servicesByAppId[appId] ?? [],
networks: networksByAppId[appId] ?? [], networks: networksByAppId[appId] ?? [],
@ -136,6 +140,7 @@ export function createApps(
appId, appId,
appUuid: servicesByAppId[appId]?.[0]?.appUuid ?? 'deadbeef', appUuid: servicesByAppId[appId]?.[0]?.appUuid ?? 'deadbeef',
isTarget: target, isTarget: target,
isRejected,
}); });
} }

View File

@ -101,35 +101,41 @@ describe('lib/contracts', () => {
it('Should correctly run containers with no requirements', async () => { it('Should correctly run containers with no requirements', async () => {
expect( expect(
contracts.containerContractsFulfilled({ contracts.containerContractsFulfilled([
service: { {
commit: 'd0',
serviceName: 'service',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
slug: 'user-container', slug: 'user-container',
}, },
optional: false, optional: false,
}, },
}), ]),
) )
.to.have.property('valid') .to.have.property('valid')
.that.equals(true); .that.equals(true);
expect( expect(
contracts.containerContractsFulfilled({ contracts.containerContractsFulfilled([
service: { {
commit: 'd0',
serviceName: 'service',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
slug: 'user-container1', slug: 'user-container1',
}, },
optional: false, optional: false,
}, },
service2: { {
commit: 'd0',
serviceName: 'service2',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
slug: 'user-container2', slug: 'user-container2',
}, },
optional: false, optional: false,
}, },
}), ]),
) )
.to.have.property('valid') .to.have.property('valid')
.that.equals(true); .that.equals(true);
@ -137,8 +143,10 @@ describe('lib/contracts', () => {
it('should correctly run containers whose requirements are satisfied', async () => { it('should correctly run containers whose requirements are satisfied', async () => {
expect( expect(
contracts.containerContractsFulfilled({ contracts.containerContractsFulfilled([
service: { {
commit: 'd0',
serviceName: 'service',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
name: 'user-container', name: 'user-container',
@ -153,14 +161,16 @@ describe('lib/contracts', () => {
}, },
optional: false, optional: false,
}, },
}), ]),
) )
.to.have.property('valid') .to.have.property('valid')
.that.equals(true); .that.equals(true);
expect( expect(
contracts.containerContractsFulfilled({ contracts.containerContractsFulfilled([
service: { {
commit: 'd0',
serviceName: 'service',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
name: 'user-container', name: 'user-container',
@ -176,14 +186,16 @@ describe('lib/contracts', () => {
}, },
optional: false, optional: false,
}, },
}), ]),
) )
.to.have.property('valid') .to.have.property('valid')
.that.equals(true); .that.equals(true);
expect( expect(
contracts.containerContractsFulfilled({ contracts.containerContractsFulfilled([
service: { {
commit: 'd0',
serviceName: 'service',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
name: 'user-container', name: 'user-container',
@ -198,14 +210,16 @@ describe('lib/contracts', () => {
}, },
optional: false, optional: false,
}, },
}), ]),
) )
.to.have.property('valid') .to.have.property('valid')
.that.equals(true); .that.equals(true);
expect( expect(
contracts.containerContractsFulfilled({ contracts.containerContractsFulfilled([
service: { {
commit: 'd0',
serviceName: 'service',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
name: 'user-container', name: 'user-container',
@ -224,14 +238,16 @@ describe('lib/contracts', () => {
}, },
optional: false, optional: false,
}, },
}), ]),
) )
.to.have.property('valid') .to.have.property('valid')
.that.equals(true); .that.equals(true);
expect( expect(
contracts.containerContractsFulfilled({ contracts.containerContractsFulfilled([
service: { {
commit: 'd0',
serviceName: 'service',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
name: 'user-container1', name: 'user-container1',
@ -245,7 +261,9 @@ describe('lib/contracts', () => {
}, },
optional: false, optional: false,
}, },
service2: { {
commit: 'd0',
serviceName: 'service2',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
name: 'user-container1', name: 'user-container1',
@ -261,15 +279,17 @@ describe('lib/contracts', () => {
}, },
optional: false, optional: false,
}, },
}), ]),
) )
.to.have.property('valid') .to.have.property('valid')
.that.equals(true); .that.equals(true);
}); });
it('Should refuse to run containers whose requirements are not satisfied', async () => { it('should refuse to run containers whose requirements are not satisfied', async () => {
let fulfilled = contracts.containerContractsFulfilled({ let fulfilled = contracts.containerContractsFulfilled([
service: { {
commit: 'd0',
serviceName: 'service',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
name: 'user-container', name: 'user-container',
@ -283,14 +303,18 @@ describe('lib/contracts', () => {
}, },
optional: false, optional: false,
}, },
}); ]);
expect(fulfilled).to.have.property('valid').that.equals(false); expect(fulfilled).to.have.property('valid').that.equals(false);
expect(fulfilled) expect(fulfilled).to.have.property('unmetServices').with.lengthOf(1);
.to.have.property('unmetServices') expect(fulfilled.unmetServices[0]).to.deep.include({
.that.deep.equals(['service']); serviceName: 'service',
commit: 'd0',
});
fulfilled = contracts.containerContractsFulfilled({ fulfilled = contracts.containerContractsFulfilled([
service: { {
commit: 'd0',
serviceName: 'service',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
name: 'user-container', name: 'user-container',
@ -304,14 +328,18 @@ describe('lib/contracts', () => {
}, },
optional: false, optional: false,
}, },
}); ]);
expect(fulfilled).to.have.property('valid').that.equals(false); expect(fulfilled).to.have.property('valid').that.equals(false);
expect(fulfilled) expect(fulfilled).to.have.property('unmetServices').with.lengthOf(1);
.to.have.property('unmetServices') expect(fulfilled.unmetServices[0]).to.deep.include({
.that.deep.equals(['service']); serviceName: 'service',
commit: 'd0',
});
fulfilled = contracts.containerContractsFulfilled({ fulfilled = contracts.containerContractsFulfilled([
service: { {
commit: 'd0',
serviceName: 'service',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
name: 'user-container', name: 'user-container',
@ -325,14 +353,18 @@ describe('lib/contracts', () => {
}, },
optional: false, optional: false,
}, },
}); ]);
expect(fulfilled).to.have.property('valid').that.equals(false); expect(fulfilled).to.have.property('valid').that.equals(false);
expect(fulfilled) expect(fulfilled).to.have.property('unmetServices').with.lengthOf(1);
.to.have.property('unmetServices') expect(fulfilled.unmetServices[0]).to.deep.include({
.that.deep.equals(['service']); serviceName: 'service',
commit: 'd0',
});
fulfilled = contracts.containerContractsFulfilled({ fulfilled = contracts.containerContractsFulfilled([
service: { {
commit: 'd0',
serviceName: 'service',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
name: 'user-container', name: 'user-container',
@ -346,14 +378,18 @@ describe('lib/contracts', () => {
}, },
optional: false, optional: false,
}, },
}); ]);
expect(fulfilled).to.have.property('valid').that.equals(false); expect(fulfilled).to.have.property('valid').that.equals(false);
expect(fulfilled) expect(fulfilled).to.have.property('unmetServices').with.lengthOf(1);
.to.have.property('unmetServices') expect(fulfilled.unmetServices[0]).to.deep.include({
.that.deep.equals(['service']); serviceName: 'service',
commit: 'd0',
});
fulfilled = contracts.containerContractsFulfilled({ fulfilled = contracts.containerContractsFulfilled([
service2: { {
commit: 'd0',
serviceName: 'service2',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
name: 'user-container2', name: 'user-container2',
@ -367,14 +403,18 @@ describe('lib/contracts', () => {
}, },
optional: false, optional: false,
}, },
}); ]);
expect(fulfilled).to.have.property('valid').that.equals(false); expect(fulfilled).to.have.property('valid').that.equals(false);
expect(fulfilled) expect(fulfilled).to.have.property('unmetServices').with.lengthOf(1);
.to.have.property('unmetServices') expect(fulfilled.unmetServices[0]).to.deep.include({
.that.deep.equals(['service2']); serviceName: 'service2',
commit: 'd0',
});
fulfilled = contracts.containerContractsFulfilled({ fulfilled = contracts.containerContractsFulfilled([
service: { {
commit: 'd0',
serviceName: 'service',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
name: 'user-container1', name: 'user-container1',
@ -388,7 +428,9 @@ describe('lib/contracts', () => {
}, },
optional: false, optional: false,
}, },
service2: { {
commit: 'd0',
serviceName: 'service2',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
name: 'user-container2', name: 'user-container2',
@ -402,18 +444,22 @@ describe('lib/contracts', () => {
}, },
optional: false, optional: false,
}, },
}); ]);
expect(fulfilled).to.have.property('valid').that.equals(false); expect(fulfilled).to.have.property('valid').that.equals(false);
expect(fulfilled) expect(fulfilled).to.have.property('unmetServices').with.lengthOf(1);
.to.have.property('unmetServices') expect(fulfilled.unmetServices[0]).to.deep.include({
.that.deep.equals(['service2']); serviceName: 'service2',
commit: 'd0',
});
}); });
describe('Optional containers', () => { describe('Optional containers', () => {
it('should correctly run passing optional containers', async () => { it('should correctly run passing optional containers', async () => {
const { valid, unmetServices, fulfilledServices } = const { valid, unmetServices, fulfilledServices } =
contracts.containerContractsFulfilled({ contracts.containerContractsFulfilled([
service1: { {
commit: 'd0',
serviceName: 'service1',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
slug: 'service1', slug: 'service1',
@ -426,16 +472,22 @@ describe('lib/contracts', () => {
}, },
optional: true, optional: true,
}, },
}); ]);
expect(valid).to.equal(true); expect(valid).to.equal(true);
expect(unmetServices).to.deep.equal([]); expect(unmetServices).to.deep.equal([]);
expect(fulfilledServices).to.deep.equal(['service1']); expect(fulfilledServices[0]).to.deep.include({
serviceName: 'service1',
commit: 'd0',
});
}); });
it('should corrrectly omit failing optional containers', async () => { it('should corrrectly omit failing optional containers', async () => {
const { valid, unmetServices, fulfilledServices } = const { valid, unmetServices, fulfilledServices } =
contracts.containerContractsFulfilled({ contracts.containerContractsFulfilled([
service1: { {
commit: 'd0',
serviceName: 'service1',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
slug: 'service1', slug: 'service1',
@ -448,14 +500,18 @@ describe('lib/contracts', () => {
}, },
optional: true, optional: true,
}, },
service2: { {
commit: 'd0',
serviceName: 'service2',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
slug: 'service2', slug: 'service2',
}, },
optional: false, optional: false,
}, },
service3: { {
commit: 'd0',
serviceName: 'service3',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
slug: 'service3', slug: 'service3',
@ -468,10 +524,12 @@ describe('lib/contracts', () => {
}, },
optional: true, optional: true,
}, },
service4: { {
commit: 'd0',
serviceName: 'service4',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
slug: 'service3', slug: 'service4',
requires: [ requires: [
{ {
type: 'arch.sw', type: 'arch.sw',
@ -481,14 +539,18 @@ describe('lib/contracts', () => {
}, },
optional: true, optional: true,
}, },
}); ]);
expect(valid).to.equal(true); expect(valid).to.equal(true);
expect(unmetServices).to.deep.equal([ expect(unmetServices.map((s) => s.serviceName)).to.deep.equal([
'service1', 'service1',
'service3', 'service3',
'service4', 'service4',
]); ]);
expect(fulfilledServices).to.deep.equal(['service2']); expect(fulfilledServices).to.have.lengthOf(1);
expect(fulfilledServices[0]).to.deep.include({
serviceName: 'service2',
commit: 'd0',
});
}); });
}); });
}); });
@ -548,8 +610,10 @@ describe('lib/contracts', () => {
const engine = await seedEngine('4.4.38-l4t-r31.0'); const engine = await seedEngine('4.4.38-l4t-r31.0');
expect( expect(
engine.containerContractsFulfilled({ engine.containerContractsFulfilled([
service: { {
commit: 'd0',
serviceName: 'service',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
slug: 'user-container', slug: 'user-container',
@ -562,14 +626,16 @@ describe('lib/contracts', () => {
}, },
optional: false, optional: false,
}, },
}), ]),
) )
.to.have.property('valid') .to.have.property('valid')
.that.equals(true); .that.equals(true);
expect( expect(
engine.containerContractsFulfilled({ engine.containerContractsFulfilled([
service: { {
commit: 'd0',
serviceName: 'service',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
slug: 'user-container', slug: 'user-container',
@ -582,7 +648,7 @@ describe('lib/contracts', () => {
}, },
optional: false, optional: false,
}, },
}), ]),
) )
.to.have.property('valid') .to.have.property('valid')
.that.equals(false); .that.equals(false);
@ -592,8 +658,10 @@ describe('lib/contracts', () => {
const engine = await seedEngine('4.4.38-l4t-r31.0.1'); const engine = await seedEngine('4.4.38-l4t-r31.0.1');
expect( expect(
engine.containerContractsFulfilled({ engine.containerContractsFulfilled([
service: { {
commit: 'd0',
serviceName: 'service',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
slug: 'user-container', slug: 'user-container',
@ -606,14 +674,16 @@ describe('lib/contracts', () => {
}, },
optional: false, optional: false,
}, },
}), ]),
) )
.to.have.property('valid') .to.have.property('valid')
.that.equals(true); .that.equals(true);
expect( expect(
engine.containerContractsFulfilled({ engine.containerContractsFulfilled([
service: { {
commit: 'd0',
serviceName: 'service',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
slug: 'user-container', slug: 'user-container',
@ -626,7 +696,7 @@ describe('lib/contracts', () => {
}, },
optional: false, optional: false,
}, },
}), ]),
) )
.to.have.property('valid') .to.have.property('valid')
.that.equals(false); .that.equals(false);