balena-supervisor/src/lib/contracts.ts
Cameron Diver 8223bf2ccb Report any optional containers that aren't being run
Change-type: patch
Signed-off-by: Cameron Diver <cameron@balena.io>
2019-11-05 14:44:22 +00:00

159 lines
3.6 KiB
TypeScript

import { isLeft } from 'fp-ts/lib/Either';
import * as t from 'io-ts';
import { reporter } from 'io-ts-reporters';
import * as _ from 'lodash';
import { Blueprint, Contract, ContractObject } from '@balena/contrato';
import constants = require('./constants');
import { InternalInconsistencyError } from './errors';
import * as osRelease from './os-release';
import supervisorVersion = require('./supervisor-version');
export { ContractObject };
export interface ServiceContracts {
[serviceName: string]: { contract?: ContractObject; optional: boolean };
}
export async function containerContractsFulfilled(
serviceContracts: ServiceContracts,
): Promise<{
valid: boolean;
unmetServices: string[];
fulfilledServices: string[];
unmetAndOptional: string[];
}> {
const containers = _(serviceContracts)
.map('contract')
.compact()
.value();
const osContract = new Contract({
slug: 'balenaOS',
type: 'sw.os',
name: 'balenaOS',
version: await osRelease.getOSSemver(constants.hostOSVersionPath),
});
const supervisorContract = new Contract({
slug: 'balena-supervisor',
type: 'sw.supervisor',
name: 'balena-supervisor',
version: supervisorVersion,
});
const blueprint = new Blueprint(
{
'sw.os': 1,
'sw.supervisor': 1,
'sw.container': '1+',
},
{
type: 'sw.runnable.configuration',
slug: '{{children.sw.container.slug}}',
},
);
const universe = new Contract({
type: 'meta.universe',
});
universe.addChildren([
osContract,
supervisorContract,
...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: _.keys(serviceContracts),
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: _.keys(serviceContracts),
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(
_.keys(serviceContracts),
serviceName => {
const { contract } = serviceContracts[serviceName];
if (!contract) {
return true;
}
// Did we find the contract in the generated state?
return _.some(children, child =>
_.isEqual((child as any).raw, contract),
);
},
);
const [unmetAndRequired, unmetAndOptional] = _.partition(
unfulfilledServices,
serviceName => {
return !serviceContracts[serviceName].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]),
}),
),
]),
});
export function validateContract(
contract: unknown,
): contract is ContractObject {
const result = contractObjectValidator.decode(contract);
if (isLeft(result)) {
throw new Error(reporter(result).join('\n'));
}
return true;
}