mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-05-19 01:02:57 +00:00
Simplify contract validation module
Use `satisfiesChildContract` instead of Blueprints as the previous implementation did. Change-type: patch
This commit is contained in:
parent
01585c688e
commit
7c83eaef80
8
package-lock.json
generated
8
package-lock.json
generated
@ -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": {
|
||||||
|
@ -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",
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
Loading…
x
Reference in New Issue
Block a user