Refactor contracts validation code

This updates the interfaces on lib/contracts and the validation in
the application-manager module.
This commit is contained in:
Felipe Lalanne 2024-06-14 12:01:15 -04:00
parent e9f460fd75
commit 48e526ec43
6 changed files with 295 additions and 334 deletions

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

@ -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';
@ -503,14 +499,14 @@ 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, $filteredApps: TargetApps,
trx: Transaction, $trx: Transaction,
) => { ) => {
await dbFormat.setApps($filteredApps, source, trx); await dbFormat.setApps($filteredApps, source, $trx);
await trx('app') await $trx('app')
.where({ source }) .where({ source })
.whereNotIn( .whereNotIn(
'appId', 'appId',
@ -534,49 +530,43 @@ export async function setTarget(
// useless - The exception to this rule is when the only // useless - The exception to this rule is when the only
// failing services are marked as optional, then we // failing services are marked as optional, then we
// filter those out and add the target state to the database // filter those out and add the target state to the database
const contractViolators: { [appName: string]: string[] } = {}; const contractViolators: contracts.ContractViolators = {};
const fulfilledContracts = validateTargetContracts(apps); const fulfilledContracts = contracts.validateTargetContracts(apps);
const filteredApps = structuredClone(apps); const filteredApps = structuredClone(apps);
_.each( for (const [
fulfilledContracts,
(
{ valid, unmetServices, fulfilledServices, unmetAndOptional },
appUuid, appUuid,
) => { { valid, unmetServices, unmetAndOptional },
] of Object.entries(fulfilledContracts)) {
if (!valid) { if (!valid) {
contractViolators[apps[appUuid].name] = unmetServices; contractViolators[appUuid] = {
return delete filteredApps[appUuid]; appId: apps[appUuid].id,
appName: apps[appUuid].name,
services: unmetServices.map(({ serviceName }) => serviceName),
};
// Remove the invalid app from the list
delete filteredApps[appUuid];
} else { } else {
// valid is true, but we could still be missing // App is valid, but we could still be missing
// some optional containers, and need to filter // some optional containers, and need to filter
// these out of the target state // these out of the target state
const [releaseUuid] = Object.keys(filteredApps[appUuid].releases); const app = filteredApps[appUuid];
if (releaseUuid) { for (const { commit, serviceName } of unmetAndOptional) {
const services = delete app.releases[commit].services[serviceName];
filteredApps[appUuid].releases[releaseUuid].services ?? {};
filteredApps[appUuid].releases[releaseUuid].services = _.pick(
services,
Object.keys(services).filter((serviceName) =>
fulfilledServices.includes(serviceName),
),
);
} }
if (unmetAndOptional.length !== 0) { if (unmetAndOptional.length !== 0) {
return reportOptionalContainers(unmetAndOptional); reportOptionalContainers(
} unmetAndOptional.map(({ serviceName }) => serviceName),
}
},
); );
let promise;
if (maybeTrx != null) {
promise = setInTransaction(filteredApps, maybeTrx);
} else {
promise = transaction((trx) => setInTransaction(filteredApps, trx));
} }
await promise; }
}
await setInTransaction(filteredApps, trx);
if (!_.isEmpty(contractViolators)) { if (!_.isEmpty(contractViolators)) {
throw new ContractViolationError(contractViolators); // TODO: add rejected state for contract violator apps
throw new contracts.ContractViolationError(contractViolators);
} }
} }

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]; for (const [appUuid, app] of Object.entries(apps)) {
const [release] = Object.values(app.releases); const releases = Object.entries(app.releases);
const serviceContracts = Object.keys(release?.services ?? []) if (releases.length === 0) {
.map((serviceName) => { continue;
const service = release.services[serviceName]; }
const { contract } = service;
// While app.releases is an object, we expect a target to only
// contain a single release per app so we use just the first element
const [commit, release] = releases[0];
const servicesWithContract = Object.entries(release.services ?? {}).map(
([serviceName, { contract, labels = {} }]) => {
if (contract) { if (contract) {
try { try {
// Validate the contract syntax
validateContract(contract); validateContract(contract);
return {
serviceName,
contract,
optional: checkTruthy(
service.labels?.['io.balena.features.optional'],
),
};
} catch (e: any) { } catch (e: any) {
throw new ContractValidationError(serviceName, e.message); 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 {}

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

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