diff --git a/src/api-binder/index.ts b/src/api-binder/index.ts index fe67b27a..887b882a 100644 --- a/src/api-binder/index.ts +++ b/src/api-binder/index.ts @@ -9,12 +9,11 @@ import * as deviceConfig from '../device-config'; import * as eventTracker from '../event-tracker'; import { loadBackupFromMigration } from '../lib/migration'; +import { InternalInconsistencyError, TargetStateError } from '../lib/errors'; import { ContractValidationError, ContractViolationError, - InternalInconsistencyError, - TargetStateError, -} from '../lib/errors'; +} from '../lib/contracts'; import log from '../lib/supervisor-console'; diff --git a/src/compose/application-manager.ts b/src/compose/application-manager.ts index 937d382d..b677bf7d 100644 --- a/src/compose/application-manager.ts +++ b/src/compose/application-manager.ts @@ -4,18 +4,14 @@ import type StrictEventEmitter from 'strict-event-emitter-types'; import * as config from '../config'; import type { Transaction } from '../db'; -import { transaction } from '../db'; import * as logger from '../logger'; import LocalModeManager from '../local-mode'; 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 log from '../lib/supervisor-console'; -import { - ContractViolationError, - InternalInconsistencyError, -} from '../lib/errors'; +import { InternalInconsistencyError } from '../lib/errors'; import { getServicesLockedByAppId, LocksTakenMap } from '../lib/update-lock'; import { checkTruthy } from '../lib/validation'; @@ -503,14 +499,14 @@ export async function executeStep( export async function setTarget( apps: TargetApps, source: string, - maybeTrx?: Transaction, + trx: Transaction, ) { const setInTransaction = async ( $filteredApps: TargetApps, - trx: Transaction, + $trx: Transaction, ) => { - await dbFormat.setApps($filteredApps, source, trx); - await trx('app') + await dbFormat.setApps($filteredApps, source, $trx); + await $trx('app') .where({ source }) .whereNotIn( 'appId', @@ -534,49 +530,43 @@ export async function setTarget( // useless - The exception to this rule is when the only // failing services are marked as optional, then we // filter those out and add the target state to the database - const contractViolators: { [appName: string]: string[] } = {}; - const fulfilledContracts = validateTargetContracts(apps); + const contractViolators: contracts.ContractViolators = {}; + const fulfilledContracts = contracts.validateTargetContracts(apps); const filteredApps = structuredClone(apps); - _.each( - fulfilledContracts, - ( - { valid, unmetServices, fulfilledServices, unmetAndOptional }, - appUuid, - ) => { - if (!valid) { - contractViolators[apps[appUuid].name] = unmetServices; - return delete filteredApps[appUuid]; - } else { - // valid is true, but we could still be missing - // some optional containers, and need to filter - // these out of the target state - const [releaseUuid] = Object.keys(filteredApps[appUuid].releases); - if (releaseUuid) { - const services = - filteredApps[appUuid].releases[releaseUuid].services ?? {}; - filteredApps[appUuid].releases[releaseUuid].services = _.pick( - services, - Object.keys(services).filter((serviceName) => - fulfilledServices.includes(serviceName), - ), - ); - } + for (const [ + appUuid, + { valid, unmetServices, unmetAndOptional }, + ] of Object.entries(fulfilledContracts)) { + if (!valid) { + contractViolators[appUuid] = { + appId: apps[appUuid].id, + appName: apps[appUuid].name, + services: unmetServices.map(({ serviceName }) => serviceName), + }; - if (unmetAndOptional.length !== 0) { - return reportOptionalContainers(unmetAndOptional); - } + // Remove the invalid app from the list + delete filteredApps[appUuid]; + } else { + // App is valid, but we could still be missing + // some optional containers, and need to filter + // these out of the target state + const app = filteredApps[appUuid]; + for (const { commit, serviceName } of unmetAndOptional) { + delete app.releases[commit].services[serviceName]; } - }, - ); - let promise; - if (maybeTrx != null) { - promise = setInTransaction(filteredApps, maybeTrx); - } else { - promise = transaction((trx) => setInTransaction(filteredApps, trx)); + + if (unmetAndOptional.length !== 0) { + reportOptionalContainers( + unmetAndOptional.map(({ serviceName }) => serviceName), + ); + } + } } - await promise; + + await setInTransaction(filteredApps, trx); if (!_.isEmpty(contractViolators)) { - throw new ContractViolationError(contractViolators); + // TODO: add rejected state for contract violator apps + throw new contracts.ContractViolationError(contractViolators); } } diff --git a/src/lib/contracts.ts b/src/lib/contracts.ts index a63d32c7..bd0da48b 100644 --- a/src/lib/contracts.ts +++ b/src/lib/contracts.ts @@ -2,23 +2,66 @@ import { isLeft } from 'fp-ts/lib/Either'; import * as t from 'io-ts'; import Reporter from 'io-ts-reporters'; import _ from 'lodash'; +import { TypedError } from 'typed-error'; import type { ContractObject } from '@balena/contrato'; import { Blueprint, Contract } from '@balena/contrato'; -import { ContractValidationError, InternalInconsistencyError } from './errors'; +import { InternalInconsistencyError } from './errors'; import { checkTruthy } from './validation'; import type { TargetApps } from '../types'; -export interface ApplicationContractResult { - valid: boolean; - unmetServices: string[]; - fulfilledServices: string[]; - unmetAndOptional: string[]; +/** + * 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}`, + ); + } } -export interface ServiceContracts { - [serviceName: string]: { contract?: ContractObject; optional: boolean }; +export interface ContractViolators { + [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 = @@ -52,12 +95,15 @@ function isValidRequirementType( } export function containerContractsFulfilled( - serviceContracts: ServiceContracts, -): ApplicationContractResult { - const containers = _(serviceContracts).map('contract').compact().value(); + servicesWithContract: ServiceWithContract[], +): AppContractResult { + const containers = servicesWithContract + .map(({ contract }) => contract) + .filter((c) => c != null) satisfies ContractObject[]; + const contractTypes = Object.keys(contractRequirementVersions); const blueprintMembership: Dictionary = {}; - for (const component of _.keys(contractRequirementVersions)) { + for (const component of contractTypes) { blueprintMembership[component] = 1; } const blueprint = new Blueprint( @@ -89,10 +135,11 @@ export function containerContractsFulfilled( 'More than one solution available for container contracts when only one is expected!', ); } + if (solution.length === 0) { return { valid: false, - unmetServices: _.keys(serviceContracts), + unmetServices: servicesWithContract, fulfilledServices: [], unmetAndOptional: [], }; @@ -108,7 +155,7 @@ export function containerContractsFulfilled( return { valid: true, unmetServices: [], - fulfilledServices: _.keys(serviceContracts), + fulfilledServices: servicesWithContract, unmetAndOptional: [], }; } else { @@ -117,16 +164,14 @@ export function containerContractsFulfilled( // those containers whose contract was not met are // marked as optional, the target state is still valid, // but we ignore the optional containers - const [fulfilledServices, unfulfilledServices] = _.partition( - _.keys(serviceContracts), - (serviceName) => { - const { contract } = serviceContracts[serviceName]; + servicesWithContract, + ({ contract }) => { if (!contract) { return true; } // Did we find the contract in the generated state? - return _.some(children, (child) => + return children.some((child) => _.isEqual((child as any).raw, contract), ); }, @@ -134,9 +179,7 @@ export function containerContractsFulfilled( const [unmetAndRequired, unmetAndOptional] = _.partition( unfulfilledServices, - (serviceName) => { - return !serviceContracts[serviceName].optional; - }, + ({ optional }) => !optional, ); return { @@ -198,67 +241,43 @@ export function validateContract(contract: unknown): boolean { return true; } + export function validateTargetContracts( apps: TargetApps, -): Dictionary { - return Object.keys(apps) - .map((appUuid): [string, ApplicationContractResult] => { - const app = apps[appUuid]; - const [release] = Object.values(app.releases); - const serviceContracts = Object.keys(release?.services ?? []) - .map((serviceName) => { - const service = release.services[serviceName]; - const { contract } = service; - if (contract) { - try { - // Validate the contract syntax - validateContract(contract); +): Dictionary { + const result: Dictionary = {}; - return { - serviceName, - contract, - optional: checkTruthy( - service.labels?.['io.balena.features.optional'], - ), - }; - } catch (e: any) { - throw new ContractValidationError(serviceName, e.message); - } + for (const [appUuid, app] of Object.entries(apps)) { + const releases = Object.entries(app.releases); + if (releases.length === 0) { + continue; + } + + // 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) { + 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 { serviceName, contract: undefined, optional: false }; - }) - // map by serviceName - .reduce( - (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, + return { + serviceName, + commit, + contract, + optional: checkTruthy(labels['io.balena.features.optional']), + }; + }, ); + + result[appUuid] = containerContractsFulfilled(servicesWithContract); + } + + return result; } diff --git a/src/lib/errors.ts b/src/lib/errors.ts index f7fb9b86..eb4a1a08 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -1,4 +1,4 @@ -import { endsWith, map } from 'lodash'; +import { endsWith } from 'lodash'; import { TypedError } from 'typed-error'; import { checkInt } from './validation'; @@ -104,39 +104,6 @@ export class TargetStateError 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 DatabaseParseError extends TypedError {} export class BackupError extends TypedError {} diff --git a/test/lib/db-helper.ts b/test/lib/db-helper.ts deleted file mode 100644 index a27d58e1..00000000 --- a/test/lib/db-helper.ts +++ /dev/null @@ -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>; diff --git a/test/unit/lib/contracts.spec.ts b/test/unit/lib/contracts.spec.ts index fd655a5a..cd204534 100644 --- a/test/unit/lib/contracts.spec.ts +++ b/test/unit/lib/contracts.spec.ts @@ -101,35 +101,41 @@ describe('lib/contracts', () => { it('Should correctly run containers with no requirements', async () => { expect( - contracts.containerContractsFulfilled({ - service: { + contracts.containerContractsFulfilled([ + { + commit: 'd0', + serviceName: 'service', contract: { type: 'sw.container', slug: 'user-container', }, optional: false, }, - }), + ]), ) .to.have.property('valid') .that.equals(true); expect( - contracts.containerContractsFulfilled({ - service: { + contracts.containerContractsFulfilled([ + { + commit: 'd0', + serviceName: 'service', contract: { type: 'sw.container', slug: 'user-container1', }, optional: false, }, - service2: { + { + commit: 'd0', + serviceName: 'service2', contract: { type: 'sw.container', slug: 'user-container2', }, optional: false, }, - }), + ]), ) .to.have.property('valid') .that.equals(true); @@ -137,8 +143,10 @@ describe('lib/contracts', () => { it('should correctly run containers whose requirements are satisfied', async () => { expect( - contracts.containerContractsFulfilled({ - service: { + contracts.containerContractsFulfilled([ + { + commit: 'd0', + serviceName: 'service', contract: { type: 'sw.container', name: 'user-container', @@ -153,14 +161,16 @@ describe('lib/contracts', () => { }, optional: false, }, - }), + ]), ) .to.have.property('valid') .that.equals(true); expect( - contracts.containerContractsFulfilled({ - service: { + contracts.containerContractsFulfilled([ + { + commit: 'd0', + serviceName: 'service', contract: { type: 'sw.container', name: 'user-container', @@ -176,14 +186,16 @@ describe('lib/contracts', () => { }, optional: false, }, - }), + ]), ) .to.have.property('valid') .that.equals(true); expect( - contracts.containerContractsFulfilled({ - service: { + contracts.containerContractsFulfilled([ + { + commit: 'd0', + serviceName: 'service', contract: { type: 'sw.container', name: 'user-container', @@ -198,14 +210,16 @@ describe('lib/contracts', () => { }, optional: false, }, - }), + ]), ) .to.have.property('valid') .that.equals(true); expect( - contracts.containerContractsFulfilled({ - service: { + contracts.containerContractsFulfilled([ + { + commit: 'd0', + serviceName: 'service', contract: { type: 'sw.container', name: 'user-container', @@ -224,14 +238,16 @@ describe('lib/contracts', () => { }, optional: false, }, - }), + ]), ) .to.have.property('valid') .that.equals(true); expect( - contracts.containerContractsFulfilled({ - service: { + contracts.containerContractsFulfilled([ + { + commit: 'd0', + serviceName: 'service', contract: { type: 'sw.container', name: 'user-container1', @@ -245,7 +261,9 @@ describe('lib/contracts', () => { }, optional: false, }, - service2: { + { + commit: 'd0', + serviceName: 'service2', contract: { type: 'sw.container', name: 'user-container1', @@ -261,15 +279,17 @@ describe('lib/contracts', () => { }, optional: false, }, - }), + ]), ) .to.have.property('valid') .that.equals(true); }); - it('Should refuse to run containers whose requirements are not satisfied', async () => { - let fulfilled = contracts.containerContractsFulfilled({ - service: { + it('should refuse to run containers whose requirements are not satisfied', async () => { + let fulfilled = contracts.containerContractsFulfilled([ + { + commit: 'd0', + serviceName: 'service', contract: { type: 'sw.container', name: 'user-container', @@ -283,14 +303,18 @@ describe('lib/contracts', () => { }, optional: false, }, - }); + ]); expect(fulfilled).to.have.property('valid').that.equals(false); - expect(fulfilled) - .to.have.property('unmetServices') - .that.deep.equals(['service']); + expect(fulfilled).to.have.property('unmetServices').with.lengthOf(1); + expect(fulfilled.unmetServices[0]).to.deep.include({ + serviceName: 'service', + commit: 'd0', + }); - fulfilled = contracts.containerContractsFulfilled({ - service: { + fulfilled = contracts.containerContractsFulfilled([ + { + commit: 'd0', + serviceName: 'service', contract: { type: 'sw.container', name: 'user-container', @@ -304,14 +328,18 @@ describe('lib/contracts', () => { }, optional: false, }, - }); + ]); expect(fulfilled).to.have.property('valid').that.equals(false); - expect(fulfilled) - .to.have.property('unmetServices') - .that.deep.equals(['service']); + expect(fulfilled).to.have.property('unmetServices').with.lengthOf(1); + expect(fulfilled.unmetServices[0]).to.deep.include({ + serviceName: 'service', + commit: 'd0', + }); - fulfilled = contracts.containerContractsFulfilled({ - service: { + fulfilled = contracts.containerContractsFulfilled([ + { + commit: 'd0', + serviceName: 'service', contract: { type: 'sw.container', name: 'user-container', @@ -325,14 +353,18 @@ describe('lib/contracts', () => { }, optional: false, }, - }); + ]); expect(fulfilled).to.have.property('valid').that.equals(false); - expect(fulfilled) - .to.have.property('unmetServices') - .that.deep.equals(['service']); + expect(fulfilled).to.have.property('unmetServices').with.lengthOf(1); + expect(fulfilled.unmetServices[0]).to.deep.include({ + serviceName: 'service', + commit: 'd0', + }); - fulfilled = contracts.containerContractsFulfilled({ - service: { + fulfilled = contracts.containerContractsFulfilled([ + { + commit: 'd0', + serviceName: 'service', contract: { type: 'sw.container', name: 'user-container', @@ -346,14 +378,18 @@ describe('lib/contracts', () => { }, optional: false, }, - }); + ]); expect(fulfilled).to.have.property('valid').that.equals(false); - expect(fulfilled) - .to.have.property('unmetServices') - .that.deep.equals(['service']); + expect(fulfilled).to.have.property('unmetServices').with.lengthOf(1); + expect(fulfilled.unmetServices[0]).to.deep.include({ + serviceName: 'service', + commit: 'd0', + }); - fulfilled = contracts.containerContractsFulfilled({ - service2: { + fulfilled = contracts.containerContractsFulfilled([ + { + commit: 'd0', + serviceName: 'service2', contract: { type: 'sw.container', name: 'user-container2', @@ -367,14 +403,18 @@ describe('lib/contracts', () => { }, optional: false, }, - }); + ]); expect(fulfilled).to.have.property('valid').that.equals(false); - expect(fulfilled) - .to.have.property('unmetServices') - .that.deep.equals(['service2']); + expect(fulfilled).to.have.property('unmetServices').with.lengthOf(1); + expect(fulfilled.unmetServices[0]).to.deep.include({ + serviceName: 'service2', + commit: 'd0', + }); - fulfilled = contracts.containerContractsFulfilled({ - service: { + fulfilled = contracts.containerContractsFulfilled([ + { + commit: 'd0', + serviceName: 'service', contract: { type: 'sw.container', name: 'user-container1', @@ -388,7 +428,9 @@ describe('lib/contracts', () => { }, optional: false, }, - service2: { + { + commit: 'd0', + serviceName: 'service2', contract: { type: 'sw.container', name: 'user-container2', @@ -402,18 +444,22 @@ describe('lib/contracts', () => { }, optional: false, }, - }); + ]); expect(fulfilled).to.have.property('valid').that.equals(false); - expect(fulfilled) - .to.have.property('unmetServices') - .that.deep.equals(['service2']); + expect(fulfilled).to.have.property('unmetServices').with.lengthOf(1); + expect(fulfilled.unmetServices[0]).to.deep.include({ + serviceName: 'service2', + commit: 'd0', + }); }); describe('Optional containers', () => { it('should correctly run passing optional containers', async () => { const { valid, unmetServices, fulfilledServices } = - contracts.containerContractsFulfilled({ - service1: { + contracts.containerContractsFulfilled([ + { + commit: 'd0', + serviceName: 'service1', contract: { type: 'sw.container', slug: 'service1', @@ -426,16 +472,22 @@ describe('lib/contracts', () => { }, optional: true, }, - }); + ]); + expect(valid).to.equal(true); 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 () => { const { valid, unmetServices, fulfilledServices } = - contracts.containerContractsFulfilled({ - service1: { + contracts.containerContractsFulfilled([ + { + commit: 'd0', + serviceName: 'service1', contract: { type: 'sw.container', slug: 'service1', @@ -448,14 +500,18 @@ describe('lib/contracts', () => { }, optional: true, }, - service2: { + { + commit: 'd0', + serviceName: 'service2', contract: { type: 'sw.container', slug: 'service2', }, optional: false, }, - service3: { + { + commit: 'd0', + serviceName: 'service3', contract: { type: 'sw.container', slug: 'service3', @@ -468,10 +524,12 @@ describe('lib/contracts', () => { }, optional: true, }, - service4: { + { + commit: 'd0', + serviceName: 'service4', contract: { type: 'sw.container', - slug: 'service3', + slug: 'service4', requires: [ { type: 'arch.sw', @@ -481,14 +539,18 @@ describe('lib/contracts', () => { }, optional: true, }, - }); + ]); expect(valid).to.equal(true); - expect(unmetServices).to.deep.equal([ + expect(unmetServices.map((s) => s.serviceName)).to.deep.equal([ 'service1', 'service3', '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'); expect( - engine.containerContractsFulfilled({ - service: { + engine.containerContractsFulfilled([ + { + commit: 'd0', + serviceName: 'service', contract: { type: 'sw.container', slug: 'user-container', @@ -562,14 +626,16 @@ describe('lib/contracts', () => { }, optional: false, }, - }), + ]), ) .to.have.property('valid') .that.equals(true); expect( - engine.containerContractsFulfilled({ - service: { + engine.containerContractsFulfilled([ + { + commit: 'd0', + serviceName: 'service', contract: { type: 'sw.container', slug: 'user-container', @@ -582,7 +648,7 @@ describe('lib/contracts', () => { }, optional: false, }, - }), + ]), ) .to.have.property('valid') .that.equals(false); @@ -592,8 +658,10 @@ describe('lib/contracts', () => { const engine = await seedEngine('4.4.38-l4t-r31.0.1'); expect( - engine.containerContractsFulfilled({ - service: { + engine.containerContractsFulfilled([ + { + commit: 'd0', + serviceName: 'service', contract: { type: 'sw.container', slug: 'user-container', @@ -606,14 +674,16 @@ describe('lib/contracts', () => { }, optional: false, }, - }), + ]), ) .to.have.property('valid') .that.equals(true); expect( - engine.containerContractsFulfilled({ - service: { + engine.containerContractsFulfilled([ + { + commit: 'd0', + serviceName: 'service', contract: { type: 'sw.container', slug: 'user-container', @@ -626,7 +696,7 @@ describe('lib/contracts', () => { }, optional: false, }, - }), + ]), ) .to.have.property('valid') .that.equals(false);