Simplify contract validation module

Use `satisfiesChildContract` instead of Blueprints as the previous
implementation did.

Change-type: patch
This commit is contained in:
Felipe Lalanne 2025-05-08 17:26:32 -04:00
parent 01585c688e
commit 7c83eaef80
No known key found for this signature in database
GPG Key ID: 0AB01F4524A42024
4 changed files with 114 additions and 173 deletions

8
package-lock.json generated
View File

@ -14,7 +14,7 @@
}, },
"devDependencies": { "devDependencies": {
"@balena/compose": "^6.0.0", "@balena/compose": "^6.0.0",
"@balena/contrato": "^0.12.0", "@balena/contrato": "^0.13.0",
"@balena/es-version": "^1.0.3", "@balena/es-version": "^1.0.3",
"@balena/lint": "^8.0.2", "@balena/lint": "^8.0.2",
"@balena/sbvr-types": "^9.1.0", "@balena/sbvr-types": "^9.1.0",
@ -460,9 +460,9 @@
} }
}, },
"node_modules/@balena/contrato": { "node_modules/@balena/contrato": {
"version": "0.12.0", "version": "0.13.0",
"resolved": "https://registry.npmjs.org/@balena/contrato/-/contrato-0.12.0.tgz", "resolved": "https://registry.npmjs.org/@balena/contrato/-/contrato-0.13.0.tgz",
"integrity": "sha512-0B6mBcGRqIPxgJ8sELd9UQFrXHKwmeZjizLespB92QRpFuVrheMtayYIXjYiJ1jJtp+KA75xwCmgtfOIftL8gg==", "integrity": "sha512-toDzvrJokqz28We9jZCbepqC/VLy22bo85ZqzfAlLqIKmfanT3VyxkbGumpDbbb9Ra31Y2h4PxkoFn+UtmGA0g==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {

View File

@ -40,7 +40,7 @@
}, },
"devDependencies": { "devDependencies": {
"@balena/compose": "^6.0.0", "@balena/compose": "^6.0.0",
"@balena/contrato": "^0.12.0", "@balena/contrato": "^0.13.0",
"@balena/es-version": "^1.0.3", "@balena/es-version": "^1.0.3",
"@balena/lint": "^8.0.2", "@balena/lint": "^8.0.2",
"@balena/sbvr-types": "^9.1.0", "@balena/sbvr-types": "^9.1.0",

View File

@ -1,15 +1,13 @@
import { isLeft } from 'fp-ts/lib/Either'; 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 { TypedError } from 'typed-error'; import { TypedError } from 'typed-error';
import type { ContractObject } from '@balena/contrato'; 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 { checkTruthy } from './validation';
import type { TargetApps } from '../types'; import { withDefault, type TargetApps } from '../types';
/** /**
* This error is thrown when a container contract does not * This error is thrown when a container contract does not
@ -64,16 +62,13 @@ interface ServiceWithContract extends ServiceCtx {
optional: boolean; optional: boolean;
} }
type PotentialContractRequirements = const validRequirementTypes = [
| 'sw.supervisor' 'sw.supervisor',
| 'sw.l4t' 'sw.l4t',
| 'hw.device-type' 'hw.device-type',
| 'arch.sw'; 'arch.sw',
type ContractRequirements = { ];
[key in PotentialContractRequirements]?: string; const deviceContract: Universe = new Universe();
};
const contractRequirementVersions: ContractRequirements = {};
export function initializeContractRequirements(opts: { export function initializeContractRequirements(opts: {
supervisorVersion: string; supervisorVersion: string;
@ -81,165 +76,108 @@ export function initializeContractRequirements(opts: {
deviceArch: string; deviceArch: string;
l4tVersion?: string; l4tVersion?: string;
}) { }) {
contractRequirementVersions['sw.supervisor'] = opts.supervisorVersion; deviceContract.addChildren([
contractRequirementVersions['sw.l4t'] = opts.l4tVersion; new Contract({
contractRequirementVersions['hw.device-type'] = opts.deviceType; type: 'sw.supervisor',
contractRequirementVersions['arch.sw'] = opts.deviceArch; 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( function isValidRequirementType(requirement: string) {
requirementVersions: ContractRequirements, return validRequirementTypes.includes(requirement);
requirement: string,
) {
return requirement in requirementVersions;
} }
// this is only exported for tests
export function containerContractsFulfilled( export function containerContractsFulfilled(
servicesWithContract: ServiceWithContract[], servicesWithContract: ServiceWithContract[],
): AppContractResult { ): AppContractResult {
const containers = servicesWithContract const unmetServices: ServiceCtx[] = [];
.map(({ contract }) => contract) const unmetAndOptional: ServiceCtx[] = [];
.filter((c) => c != null) satisfies ContractObject[]; const fulfilledServices: ServiceCtx[] = [];
const contractTypes = Object.keys(contractRequirementVersions); for (const svc of servicesWithContract) {
if (
const blueprintMembership: Dictionary<number> = {}; svc.contract != null &&
for (const component of contractTypes) { !deviceContract.satisfiesChildContract(new Contract(svc.contract))
blueprintMembership[component] = 1; ) {
} unmetServices.push(svc);
const blueprint = new Blueprint( if (svc.optional) {
{ unmetAndOptional.push(svc);
...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,
};
} else { } else {
return { fulfilledServices.push(svc);
type: component,
slug: component,
name: component,
version: value,
};
} }
}); }
return {
valid: unmetServices.length - unmetAndOptional.length === 0,
unmetServices,
fulfilledServices,
unmetAndOptional,
};
} }
export function validateContract(contract: unknown): boolean { const ContainerContract = t.intersection([
const result = contractObjectValidator.decode(contract); 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)) { if (isLeft(result)) {
throw new Error(Reporter.report(result).join('\n')); throw new Error(Reporter.report(result).join('\n'));
} }
const requirementVersions = contractRequirementVersions;
for (const { type } of result.right.requires || []) { 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`); throw new Error(`${type} is not a valid contract requirement type`);
} }
} }
return true; return result.right;
} }
export function validateTargetContracts( export function validateTargetContracts(
@ -261,7 +199,7 @@ export function validateTargetContracts(
([serviceName, { contract, labels = {} }]) => { ([serviceName, { contract, labels = {} }]) => {
if (contract) { if (contract) {
try { try {
validateContract(contract); contract = parseContract(contract);
} catch (e: any) { } catch (e: any) {
throw new ContractValidationError(serviceName, e.message); throw new ContractValidationError(serviceName, e.message);
} }

View File

@ -22,31 +22,35 @@ describe('lib/contracts', () => {
describe('Contract validation', () => { describe('Contract validation', () => {
it('should correctly validate a contract with no requirements', () => it('should correctly validate a contract with no requirements', () =>
expect(() => expect(() =>
contracts.validateContract({ contracts.parseContract({
slug: 'user-container', slug: 'user-container',
}), }),
).to.be.not.throw()); ).to.be.not.throw());
it('should correctly validate a contract with extra fields', () => it('should correctly validate a contract with extra fields', () =>
expect(() => expect(() =>
contracts.validateContract({ contracts.parseContract({
slug: 'user-container', slug: 'user-container',
name: 'user-container', name: 'user-container',
version: '3.0.0', version: '3.0.0',
}), }),
).to.be.not.throw()); ).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([ return Promise.all([
expect(() => contracts.validateContract({})).to.throw(), expect(() => contracts.parseContract({ type: 1234 })).to.throw(),
expect(() => contracts.validateContract({ name: 'test' })).to.throw(), expect(() =>
expect(() => contracts.validateContract({ requires: [] })).to.throw(), 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(() => expect(() =>
contracts.validateContract({ contracts.parseContract({
slug: 'user-container', slug: 'user-container',
requires: [ requires: [
{ {
@ -60,11 +64,12 @@ describe('lib/contracts', () => {
{ type: 'arch.sw', slug: 'aarch64' }, { type: 'arch.sw', slug: 'aarch64' },
], ],
}), }),
).to.not.throw()); ).to.not.throw();
});
it('should not validate a contract with requirements without the minimum required fields', () => { it('should not validate a contract with requirements without the minimum required fields', () => {
return expect(() => return expect(() =>
contracts.validateContract({ contracts.parseContract({
slug: 'user-container', slug: 'user-container',
requires: [ requires: [
{ {
@ -205,7 +210,7 @@ describe('lib/contracts', () => {
type: 'sw.supervisor', type: 'sw.supervisor',
version: `>${supervisorVersionLesser}`, version: `>${supervisorVersionLesser}`,
}, },
{ type: 'sw.arch', slug: 'amd64' }, { type: 'arch.sw', slug: 'amd64' },
], ],
}, },
optional: false, optional: false,
@ -269,8 +274,7 @@ describe('lib/contracts', () => {
name: 'user-container1', name: 'user-container1',
slug: 'user-container1', slug: 'user-container1',
requires: [ requires: [
// sw.os is not a supported contract type, so validation // sw.os is not provided by the device contract so it should not be validated
// ignores this requirement
{ {
type: 'sw.os', type: 'sw.os',
version: '<3.0.0', version: '<3.0.0',
@ -282,7 +286,7 @@ describe('lib/contracts', () => {
]), ]),
) )
.to.have.property('valid') .to.have.property('valid')
.that.equals(true); .that.equals(false);
}); });
it('should refuse to run containers whose requirements are not satisfied', async () => { it('should refuse to run containers whose requirements are not satisfied', async () => {
@ -292,7 +296,6 @@ describe('lib/contracts', () => {
serviceName: 'service', serviceName: 'service',
contract: { contract: {
type: 'sw.container', type: 'sw.container',
name: 'user-container',
slug: 'user-container', slug: 'user-container',
requires: [ requires: [
{ {