diff --git a/package-lock.json b/package-lock.json index 75ea5e23..9da5e09a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ }, "devDependencies": { "@balena/compose": "^6.0.0", - "@balena/contrato": "^0.12.0", + "@balena/contrato": "^0.13.0", "@balena/es-version": "^1.0.3", "@balena/lint": "^8.0.2", "@balena/sbvr-types": "^9.1.0", @@ -460,9 +460,9 @@ } }, "node_modules/@balena/contrato": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@balena/contrato/-/contrato-0.12.0.tgz", - "integrity": "sha512-0B6mBcGRqIPxgJ8sELd9UQFrXHKwmeZjizLespB92QRpFuVrheMtayYIXjYiJ1jJtp+KA75xwCmgtfOIftL8gg==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@balena/contrato/-/contrato-0.13.0.tgz", + "integrity": "sha512-toDzvrJokqz28We9jZCbepqC/VLy22bo85ZqzfAlLqIKmfanT3VyxkbGumpDbbb9Ra31Y2h4PxkoFn+UtmGA0g==", "dev": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index a137a275..c4fe3edd 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ }, "devDependencies": { "@balena/compose": "^6.0.0", - "@balena/contrato": "^0.12.0", + "@balena/contrato": "^0.13.0", "@balena/es-version": "^1.0.3", "@balena/lint": "^8.0.2", "@balena/sbvr-types": "^9.1.0", diff --git a/src/lib/contracts.ts b/src/lib/contracts.ts index dbc7347d..81583e2b 100644 --- a/src/lib/contracts.ts +++ b/src/lib/contracts.ts @@ -1,15 +1,13 @@ 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 { Contract, Universe } from '@balena/contrato'; -import { InternalInconsistencyError } from './errors'; import { checkTruthy } from './validation'; -import type { TargetApps } from '../types'; +import { withDefault, type TargetApps } from '../types'; /** * This error is thrown when a container contract does not @@ -64,16 +62,13 @@ interface ServiceWithContract extends ServiceCtx { optional: boolean; } -type PotentialContractRequirements = - | 'sw.supervisor' - | 'sw.l4t' - | 'hw.device-type' - | 'arch.sw'; -type ContractRequirements = { - [key in PotentialContractRequirements]?: string; -}; - -const contractRequirementVersions: ContractRequirements = {}; +const validRequirementTypes = [ + 'sw.supervisor', + 'sw.l4t', + 'hw.device-type', + 'arch.sw', +]; +const deviceContract: Universe = new Universe(); export function initializeContractRequirements(opts: { supervisorVersion: string; @@ -81,165 +76,108 @@ export function initializeContractRequirements(opts: { deviceArch: string; l4tVersion?: string; }) { - contractRequirementVersions['sw.supervisor'] = opts.supervisorVersion; - contractRequirementVersions['sw.l4t'] = opts.l4tVersion; - contractRequirementVersions['hw.device-type'] = opts.deviceType; - contractRequirementVersions['arch.sw'] = opts.deviceArch; + deviceContract.addChildren([ + new Contract({ + type: 'sw.supervisor', + version: opts.supervisorVersion, + }), + new Contract({ + type: 'sw.application', + slug: 'balena-supervisor', + version: opts.supervisorVersion, + }), + new Contract({ + type: 'hw.device-type', + slug: opts.deviceType, + }), + new Contract({ + type: 'arch.sw', + slug: opts.deviceArch, + }), + ]); + + if (opts.l4tVersion) { + deviceContract.addChild( + new Contract({ + type: 'sw.l4t', + version: opts.l4tVersion, + }), + ); + } } -function isValidRequirementType( - requirementVersions: ContractRequirements, - requirement: string, -) { - return requirement in requirementVersions; +function isValidRequirementType(requirement: string) { + return validRequirementTypes.includes(requirement); } +// this is only exported for tests export function containerContractsFulfilled( 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 contractTypes) { - blueprintMembership[component] = 1; - } - const blueprint = new Blueprint( - { - ...blueprintMembership, - 'sw.container': '1+', - }, - { - type: 'sw.runnable.configuration', - slug: '{{children.sw.container.slug}}', - }, - ); - - const universe = new Contract({ - type: 'meta.universe', - }); - - universe.addChildren( - [ - ...getContractsFromVersions(contractRequirementVersions), - ...containers, - ].map((c) => new Contract(c)), - ); - - const solution = [...blueprint.reproduce(universe)]; - - if (solution.length > 1) { - throw new InternalInconsistencyError( - 'More than one solution available for container contracts when only one is expected!', - ); - } - - if (solution.length === 0) { - return { - valid: false, - unmetServices: servicesWithContract, - fulfilledServices: [], - unmetAndOptional: [], - }; - } - - // Detect how many containers are present in the resulting - // solution - const children = solution[0].getChildren({ - types: new Set(['sw.container']), - }); - - if (children.length === containers.length) { - return { - valid: true, - unmetServices: [], - fulfilledServices: servicesWithContract, - unmetAndOptional: [], - }; - } else { - // If we got here, it means that at least one of the - // container contracts was not fulfilled. If *all* of - // 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( - servicesWithContract, - ({ contract }) => { - if (!contract) { - return true; - } - // Did we find the contract in the generated state? - return children.some((child) => - _.isEqual((child as any).raw, contract), - ); - }, - ); - - const [unmetAndRequired, unmetAndOptional] = _.partition( - unfulfilledServices, - ({ optional }) => !optional, - ); - - return { - valid: unmetAndRequired.length === 0, - unmetServices: unfulfilledServices, - fulfilledServices, - unmetAndOptional, - }; - } -} - -const contractObjectValidator = t.type({ - slug: t.string, - requires: t.union([ - t.null, - t.undefined, - t.array( - t.type({ - type: t.string, - version: t.union([t.null, t.undefined, t.string]), - }), - ), - ]), -}); - -function getContractsFromVersions(components: ContractRequirements) { - return _.map(components, (value, component) => { - if (component === 'hw.device-type' || component === 'arch.sw') { - return { - type: component, - slug: value, - name: value, - }; + const unmetServices: ServiceCtx[] = []; + const unmetAndOptional: ServiceCtx[] = []; + const fulfilledServices: ServiceCtx[] = []; + for (const svc of servicesWithContract) { + if ( + svc.contract != null && + !deviceContract.satisfiesChildContract(new Contract(svc.contract)) + ) { + unmetServices.push(svc); + if (svc.optional) { + unmetAndOptional.push(svc); + } } else { - return { - type: component, - slug: component, - name: component, - version: value, - }; + fulfilledServices.push(svc); } - }); + } + + return { + valid: unmetServices.length - unmetAndOptional.length === 0, + unmetServices, + fulfilledServices, + unmetAndOptional, + }; } -export function validateContract(contract: unknown): boolean { - const result = contractObjectValidator.decode(contract); +const ContainerContract = t.intersection([ + t.type({ + type: withDefault(t.string, 'sw.container'), + }), + t.partial({ + slug: t.union([t.null, t.undefined, t.string]), + requires: t.union([ + t.null, + t.undefined, + t.array( + t.intersection([ + t.type({ + type: t.string, + }), + t.partial({ + slug: t.union([t.null, t.undefined, t.string]), + version: t.union([t.null, t.undefined, t.string]), + }), + ]), + ), + ]), + }), +]); + +// Exported for tests only +export function parseContract(contract: unknown): ContractObject { + const result = ContainerContract.decode(contract); if (isLeft(result)) { throw new Error(Reporter.report(result).join('\n')); } - const requirementVersions = contractRequirementVersions; - for (const { type } of result.right.requires || []) { - if (!isValidRequirementType(requirementVersions, type)) { + if (!isValidRequirementType(type)) { throw new Error(`${type} is not a valid contract requirement type`); } } - return true; + return result.right; } export function validateTargetContracts( @@ -261,7 +199,7 @@ export function validateTargetContracts( ([serviceName, { contract, labels = {} }]) => { if (contract) { try { - validateContract(contract); + contract = parseContract(contract); } catch (e: any) { throw new ContractValidationError(serviceName, e.message); } diff --git a/test/unit/lib/contracts.spec.ts b/test/unit/lib/contracts.spec.ts index cd204534..303bb530 100644 --- a/test/unit/lib/contracts.spec.ts +++ b/test/unit/lib/contracts.spec.ts @@ -22,31 +22,35 @@ describe('lib/contracts', () => { describe('Contract validation', () => { it('should correctly validate a contract with no requirements', () => expect(() => - contracts.validateContract({ + contracts.parseContract({ slug: 'user-container', }), ).to.be.not.throw()); it('should correctly validate a contract with extra fields', () => expect(() => - contracts.validateContract({ + contracts.parseContract({ slug: 'user-container', name: 'user-container', version: '3.0.0', }), ).to.be.not.throw()); - it('should not validate a contract without the minimum required fields', () => { + it('should not validate a contract with fields of invalid type', () => { return Promise.all([ - expect(() => contracts.validateContract({})).to.throw(), - expect(() => contracts.validateContract({ name: 'test' })).to.throw(), - expect(() => contracts.validateContract({ requires: [] })).to.throw(), + expect(() => contracts.parseContract({ type: 1234 })).to.throw(), + expect(() => + contracts.parseContract({ slug: true, name: 'test' }), + ).to.throw(), + expect(() => + contracts.parseContract({ requires: 'my-requirement' }), + ).to.throw(), ]); }); - it('should correctly validate a contract with requirements', () => + it('should correctly validate a contract with requirements', () => { expect(() => - contracts.validateContract({ + contracts.parseContract({ slug: 'user-container', requires: [ { @@ -60,11 +64,12 @@ describe('lib/contracts', () => { { type: 'arch.sw', slug: 'aarch64' }, ], }), - ).to.not.throw()); + ).to.not.throw(); + }); it('should not validate a contract with requirements without the minimum required fields', () => { return expect(() => - contracts.validateContract({ + contracts.parseContract({ slug: 'user-container', requires: [ { @@ -205,7 +210,7 @@ describe('lib/contracts', () => { type: 'sw.supervisor', version: `>${supervisorVersionLesser}`, }, - { type: 'sw.arch', slug: 'amd64' }, + { type: 'arch.sw', slug: 'amd64' }, ], }, optional: false, @@ -269,8 +274,7 @@ describe('lib/contracts', () => { name: 'user-container1', slug: 'user-container1', requires: [ - // sw.os is not a supported contract type, so validation - // ignores this requirement + // sw.os is not provided by the device contract so it should not be validated { type: 'sw.os', version: '<3.0.0', @@ -282,7 +286,7 @@ describe('lib/contracts', () => { ]), ) .to.have.property('valid') - .that.equals(true); + .that.equals(false); }); it('should refuse to run containers whose requirements are not satisfied', async () => { @@ -292,7 +296,6 @@ describe('lib/contracts', () => { serviceName: 'service', contract: { type: 'sw.container', - name: 'user-container', slug: 'user-container', requires: [ {