Skip to content

Commit 7c83eae

Browse files
committed
Simplify contract validation module
Use `satisfiesChildContract` instead of Blueprints as the previous implementation did. Change-type: patch
1 parent 01585c6 commit 7c83eae

File tree

4 files changed

+114
-173
lines changed

4 files changed

+114
-173
lines changed

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
},
4141
"devDependencies": {
4242
"@balena/compose": "^6.0.0",
43-
"@balena/contrato": "^0.12.0",
43+
"@balena/contrato": "^0.13.0",
4444
"@balena/es-version": "^1.0.3",
4545
"@balena/lint": "^8.0.2",
4646
"@balena/sbvr-types": "^9.1.0",

src/lib/contracts.ts

Lines changed: 91 additions & 153 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
import { isLeft } from 'fp-ts/lib/Either';
22
import * as t from 'io-ts';
33
import Reporter from 'io-ts-reporters';
4-
import _ from 'lodash';
54
import { TypedError } from 'typed-error';
65

76
import type { ContractObject } from '@balena/contrato';
8-
import { Blueprint, Contract } from '@balena/contrato';
7+
import { Contract, Universe } from '@balena/contrato';
98

10-
import { InternalInconsistencyError } from './errors';
119
import { checkTruthy } from './validation';
12-
import type { TargetApps } from '../types';
10+
import { withDefault, type TargetApps } from '../types';
1311

1412
/**
1513
* This error is thrown when a container contract does not
@@ -64,182 +62,122 @@ interface ServiceWithContract extends ServiceCtx {
6462
optional: boolean;
6563
}
6664

67-
type PotentialContractRequirements =
68-
| 'sw.supervisor'
69-
| 'sw.l4t'
70-
| 'hw.device-type'
71-
| 'arch.sw';
72-
type ContractRequirements = {
73-
[key in PotentialContractRequirements]?: string;
74-
};
75-
76-
const contractRequirementVersions: ContractRequirements = {};
65+
const validRequirementTypes = [
66+
'sw.supervisor',
67+
'sw.l4t',
68+
'hw.device-type',
69+
'arch.sw',
70+
];
71+
const deviceContract: Universe = new Universe();
7772

7873
export function initializeContractRequirements(opts: {
7974
supervisorVersion: string;
8075
deviceType: string;
8176
deviceArch: string;
8277
l4tVersion?: string;
8378
}) {
84-
contractRequirementVersions['sw.supervisor'] = opts.supervisorVersion;
85-
contractRequirementVersions['sw.l4t'] = opts.l4tVersion;
86-
contractRequirementVersions['hw.device-type'] = opts.deviceType;
87-
contractRequirementVersions['arch.sw'] = opts.deviceArch;
79+
deviceContract.addChildren([
80+
new Contract({
81+
type: 'sw.supervisor',
82+
version: opts.supervisorVersion,
83+
}),
84+
new Contract({
85+
type: 'sw.application',
86+
slug: 'balena-supervisor',
87+
version: opts.supervisorVersion,
88+
}),
89+
new Contract({
90+
type: 'hw.device-type',
91+
slug: opts.deviceType,
92+
}),
93+
new Contract({
94+
type: 'arch.sw',
95+
slug: opts.deviceArch,
96+
}),
97+
]);
98+
99+
if (opts.l4tVersion) {
100+
deviceContract.addChild(
101+
new Contract({
102+
type: 'sw.l4t',
103+
version: opts.l4tVersion,
104+
}),
105+
);
106+
}
88107
}
89108

90-
function isValidRequirementType(
91-
requirementVersions: ContractRequirements,
92-
requirement: string,
93-
) {
94-
return requirement in requirementVersions;
109+
function isValidRequirementType(requirement: string) {
110+
return validRequirementTypes.includes(requirement);
95111
}
96112

113+
// this is only exported for tests
97114
export function containerContractsFulfilled(
98115
servicesWithContract: ServiceWithContract[],
99116
): AppContractResult {
100-
const containers = servicesWithContract
101-
.map(({ contract }) => contract)
102-
.filter((c) => c != null) satisfies ContractObject[];
103-
const contractTypes = Object.keys(contractRequirementVersions);
104-
105-
const blueprintMembership: Dictionary<number> = {};
106-
for (const component of contractTypes) {
107-
blueprintMembership[component] = 1;
108-
}
109-
const blueprint = new Blueprint(
110-
{
111-
...blueprintMembership,
112-
'sw.container': '1+',
113-
},
114-
{
115-
type: 'sw.runnable.configuration',
116-
slug: '{{children.sw.container.slug}}',
117-
},
118-
);
119-
120-
const universe = new Contract({
121-
type: 'meta.universe',
122-
});
123-
124-
universe.addChildren(
125-
[
126-
...getContractsFromVersions(contractRequirementVersions),
127-
...containers,
128-
].map((c) => new Contract(c)),
129-
);
130-
131-
const solution = [...blueprint.reproduce(universe)];
132-
133-
if (solution.length > 1) {
134-
throw new InternalInconsistencyError(
135-
'More than one solution available for container contracts when only one is expected!',
136-
);
137-
}
138-
139-
if (solution.length === 0) {
140-
return {
141-
valid: false,
142-
unmetServices: servicesWithContract,
143-
fulfilledServices: [],
144-
unmetAndOptional: [],
145-
};
146-
}
147-
148-
// Detect how many containers are present in the resulting
149-
// solution
150-
const children = solution[0].getChildren({
151-
types: new Set(['sw.container']),
152-
});
153-
154-
if (children.length === containers.length) {
155-
return {
156-
valid: true,
157-
unmetServices: [],
158-
fulfilledServices: servicesWithContract,
159-
unmetAndOptional: [],
160-
};
161-
} else {
162-
// If we got here, it means that at least one of the
163-
// container contracts was not fulfilled. If *all* of
164-
// those containers whose contract was not met are
165-
// marked as optional, the target state is still valid,
166-
// but we ignore the optional containers
167-
const [fulfilledServices, unfulfilledServices] = _.partition(
168-
servicesWithContract,
169-
({ contract }) => {
170-
if (!contract) {
171-
return true;
172-
}
173-
// Did we find the contract in the generated state?
174-
return children.some((child) =>
175-
_.isEqual((child as any).raw, contract),
176-
);
177-
},
178-
);
179-
180-
const [unmetAndRequired, unmetAndOptional] = _.partition(
181-
unfulfilledServices,
182-
({ optional }) => !optional,
183-
);
184-
185-
return {
186-
valid: unmetAndRequired.length === 0,
187-
unmetServices: unfulfilledServices,
188-
fulfilledServices,
189-
unmetAndOptional,
190-
};
191-
}
192-
}
193-
194-
const contractObjectValidator = t.type({
195-
slug: t.string,
196-
requires: t.union([
197-
t.null,
198-
t.undefined,
199-
t.array(
200-
t.type({
201-
type: t.string,
202-
version: t.union([t.null, t.undefined, t.string]),
203-
}),
204-
),
205-
]),
206-
});
207-
208-
function getContractsFromVersions(components: ContractRequirements) {
209-
return _.map(components, (value, component) => {
210-
if (component === 'hw.device-type' || component === 'arch.sw') {
211-
return {
212-
type: component,
213-
slug: value,
214-
name: value,
215-
};
117+
const unmetServices: ServiceCtx[] = [];
118+
const unmetAndOptional: ServiceCtx[] = [];
119+
const fulfilledServices: ServiceCtx[] = [];
120+
for (const svc of servicesWithContract) {
121+
if (
122+
svc.contract != null &&
123+
!deviceContract.satisfiesChildContract(new Contract(svc.contract))
124+
) {
125+
unmetServices.push(svc);
126+
if (svc.optional) {
127+
unmetAndOptional.push(svc);
128+
}
216129
} else {
217-
return {
218-
type: component,
219-
slug: component,
220-
name: component,
221-
version: value,
222-
};
130+
fulfilledServices.push(svc);
223131
}
224-
});
132+
}
133+
134+
return {
135+
valid: unmetServices.length - unmetAndOptional.length === 0,
136+
unmetServices,
137+
fulfilledServices,
138+
unmetAndOptional,
139+
};
225140
}
226141

227-
export function validateContract(contract: unknown): boolean {
228-
const result = contractObjectValidator.decode(contract);
142+
const ContainerContract = t.intersection([
143+
t.type({
144+
type: withDefault(t.string, 'sw.container'),
145+
}),
146+
t.partial({
147+
slug: t.union([t.null, t.undefined, t.string]),
148+
requires: t.union([
149+
t.null,
150+
t.undefined,
151+
t.array(
152+
t.intersection([
153+
t.type({
154+
type: t.string,
155+
}),
156+
t.partial({
157+
slug: t.union([t.null, t.undefined, t.string]),
158+
version: t.union([t.null, t.undefined, t.string]),
159+
}),
160+
]),
161+
),
162+
]),
163+
}),
164+
]);
165+
166+
// Exported for tests only
167+
export function parseContract(contract: unknown): ContractObject {
168+
const result = ContainerContract.decode(contract);
229169

230170
if (isLeft(result)) {
231171
throw new Error(Reporter.report(result).join('\n'));
232172
}
233173

234-
const requirementVersions = contractRequirementVersions;
235-
236174
for (const { type } of result.right.requires || []) {
237-
if (!isValidRequirementType(requirementVersions, type)) {
175+
if (!isValidRequirementType(type)) {
238176
throw new Error(`${type} is not a valid contract requirement type`);
239177
}
240178
}
241179

242-
return true;
180+
return result.right;
243181
}
244182

245183
export function validateTargetContracts(
@@ -261,7 +199,7 @@ export function validateTargetContracts(
261199
([serviceName, { contract, labels = {} }]) => {
262200
if (contract) {
263201
try {
264-
validateContract(contract);
202+
contract = parseContract(contract);
265203
} catch (e: any) {
266204
throw new ContractValidationError(serviceName, e.message);
267205
}

0 commit comments

Comments
 (0)