Skip to content

Commit

Permalink
Refactor contracts validation code
Browse files Browse the repository at this point in the history
This updates the interfaces on lib/contracts and the validation in
the application-manager module.
  • Loading branch information
pipex committed Aug 30, 2024
1 parent e9f460f commit 48e526e
Show file tree
Hide file tree
Showing 6 changed files with 295 additions and 334 deletions.
5 changes: 2 additions & 3 deletions src/api-binder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,11 @@ import * as deviceConfig from '../device-config';
import * as eventTracker from '../event-tracker';
import { loadBackupFromMigration } from '../lib/migration';

import { InternalInconsistencyError, TargetStateError } from '../lib/errors';
import {
ContractValidationError,
ContractViolationError,
InternalInconsistencyError,
TargetStateError,
} from '../lib/errors';
} from '../lib/contracts';

import log from '../lib/supervisor-console';

Expand Down
86 changes: 38 additions & 48 deletions src/compose/application-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,14 @@ import type StrictEventEmitter from 'strict-event-emitter-types';

import * as config from '../config';
import type { Transaction } from '../db';
import { transaction } from '../db';
import * as logger from '../logger';
import LocalModeManager from '../local-mode';

import * as dbFormat from '../device-state/db-format';
import { validateTargetContracts } from '../lib/contracts';
import * as contracts from '../lib/contracts';
import * as constants from '../lib/constants';
import log from '../lib/supervisor-console';
import {
ContractViolationError,
InternalInconsistencyError,
} from '../lib/errors';
import { InternalInconsistencyError } from '../lib/errors';
import { getServicesLockedByAppId, LocksTakenMap } from '../lib/update-lock';
import { checkTruthy } from '../lib/validation';

Expand Down Expand Up @@ -503,14 +499,14 @@ export async function executeStep(
export async function setTarget(
apps: TargetApps,
source: string,
maybeTrx?: Transaction,
trx: Transaction,
) {
const setInTransaction = async (
$filteredApps: TargetApps,
trx: Transaction,
$trx: Transaction,
) => {
await dbFormat.setApps($filteredApps, source, trx);
await trx('app')
await dbFormat.setApps($filteredApps, source, $trx);
await $trx('app')
.where({ source })
.whereNotIn(
'appId',
Expand All @@ -534,49 +530,43 @@ export async function setTarget(
// useless - The exception to this rule is when the only
// failing services are marked as optional, then we
// filter those out and add the target state to the database
const contractViolators: { [appName: string]: string[] } = {};
const fulfilledContracts = validateTargetContracts(apps);
const contractViolators: contracts.ContractViolators = {};
const fulfilledContracts = contracts.validateTargetContracts(apps);
const filteredApps = structuredClone(apps);
_.each(
fulfilledContracts,
(
{ valid, unmetServices, fulfilledServices, unmetAndOptional },
appUuid,
) => {
if (!valid) {
contractViolators[apps[appUuid].name] = unmetServices;
return delete filteredApps[appUuid];
} else {
// valid is true, but we could still be missing
// some optional containers, and need to filter
// these out of the target state
const [releaseUuid] = Object.keys(filteredApps[appUuid].releases);
if (releaseUuid) {
const services =
filteredApps[appUuid].releases[releaseUuid].services ?? {};
filteredApps[appUuid].releases[releaseUuid].services = _.pick(
services,
Object.keys(services).filter((serviceName) =>
fulfilledServices.includes(serviceName),
),
);
}
for (const [
appUuid,
{ valid, unmetServices, unmetAndOptional },
] of Object.entries(fulfilledContracts)) {
if (!valid) {
contractViolators[appUuid] = {
appId: apps[appUuid].id,
appName: apps[appUuid].name,
services: unmetServices.map(({ serviceName }) => serviceName),
};

if (unmetAndOptional.length !== 0) {
return reportOptionalContainers(unmetAndOptional);
}
// Remove the invalid app from the list
delete filteredApps[appUuid];
} else {
// App is valid, but we could still be missing
// some optional containers, and need to filter
// these out of the target state
const app = filteredApps[appUuid];
for (const { commit, serviceName } of unmetAndOptional) {
delete app.releases[commit].services[serviceName];
}
},
);
let promise;
if (maybeTrx != null) {
promise = setInTransaction(filteredApps, maybeTrx);
} else {
promise = transaction((trx) => setInTransaction(filteredApps, trx));

if (unmetAndOptional.length !== 0) {
reportOptionalContainers(
unmetAndOptional.map(({ serviceName }) => serviceName),
);
}
}
}
await promise;

await setInTransaction(filteredApps, trx);
if (!_.isEmpty(contractViolators)) {
throw new ContractViolationError(contractViolators);
// TODO: add rejected state for contract violator apps
throw new contracts.ContractViolationError(contractViolators);
}
}

Expand Down
177 changes: 98 additions & 79 deletions src/lib/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,66 @@ 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 { ContractValidationError, InternalInconsistencyError } from './errors';
import { InternalInconsistencyError } from './errors';
import { checkTruthy } from './validation';
import type { TargetApps } from '../types';

export interface ApplicationContractResult {
/**
* This error is thrown when a container contract does not
* match the minimum we expect from it
*/
export class ContractValidationError extends TypedError {
constructor(serviceName: string, error: string) {
super(
`The contract for service ${serviceName} failed validation, with error: ${error}`,
);
}
}

export interface ContractViolators {
[appUuid: string]: { appName: string; appId: number; services: string[] };
}

/**
* This error is thrown when one or releases cannot be ran
* as one or more of their container have unmet requirements.
* It accepts a map of app names to arrays of service names
* which have unmet requirements.
*/
export class ContractViolationError extends TypedError {
constructor(violators: ContractViolators) {
const appStrings = Object.values(violators).map(
({ appName, services }) =>
`${appName}: Services with unmet requirements: ${services.join(', ')}`,
);
super(
`Some releases were rejected due to having unmet requirements:\n ${appStrings.join(
'\n ',
)}`,
);
}
}

export interface ServiceCtx {
serviceName: string;
commit: string;
}

export interface AppContractResult {
valid: boolean;
unmetServices: string[];
fulfilledServices: string[];
unmetAndOptional: string[];
unmetServices: ServiceCtx[];
fulfilledServices: ServiceCtx[];
unmetAndOptional: ServiceCtx[];
}

export interface ServiceContracts {
[serviceName: string]: { contract?: ContractObject; optional: boolean };
interface ServiceWithContract extends ServiceCtx {
contract?: ContractObject;
optional: boolean;
}

type PotentialContractRequirements =
Expand Down Expand Up @@ -52,12 +95,15 @@ function isValidRequirementType(
}

export function containerContractsFulfilled(
serviceContracts: ServiceContracts,
): ApplicationContractResult {
const containers = _(serviceContracts).map('contract').compact().value();
servicesWithContract: ServiceWithContract[],
): AppContractResult {
const containers = servicesWithContract
.map(({ contract }) => contract)
.filter((c) => c != null) satisfies ContractObject[];
const contractTypes = Object.keys(contractRequirementVersions);

const blueprintMembership: Dictionary<number> = {};
for (const component of _.keys(contractRequirementVersions)) {
for (const component of contractTypes) {
blueprintMembership[component] = 1;
}
const blueprint = new Blueprint(
Expand Down Expand Up @@ -89,10 +135,11 @@ export function containerContractsFulfilled(
'More than one solution available for container contracts when only one is expected!',
);
}

if (solution.length === 0) {
return {
valid: false,
unmetServices: _.keys(serviceContracts),
unmetServices: servicesWithContract,
fulfilledServices: [],
unmetAndOptional: [],
};
Expand All @@ -108,7 +155,7 @@ export function containerContractsFulfilled(
return {
valid: true,
unmetServices: [],
fulfilledServices: _.keys(serviceContracts),
fulfilledServices: servicesWithContract,
unmetAndOptional: [],
};
} else {
Expand All @@ -117,26 +164,22 @@ export function containerContractsFulfilled(
// 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];
servicesWithContract,
({ contract }) => {
if (!contract) {
return true;
}
// Did we find the contract in the generated state?
return _.some(children, (child) =>
return children.some((child) =>
_.isEqual((child as any).raw, contract),
);
},
);

const [unmetAndRequired, unmetAndOptional] = _.partition(
unfulfilledServices,
(serviceName) => {
return !serviceContracts[serviceName].optional;
},
({ optional }) => !optional,
);

return {
Expand Down Expand Up @@ -198,67 +241,43 @@ export function validateContract(contract: unknown): boolean {

return true;
}

export function validateTargetContracts(
apps: TargetApps,
): Dictionary<ApplicationContractResult> {
return Object.keys(apps)
.map((appUuid): [string, ApplicationContractResult] => {
const app = apps[appUuid];
const [release] = Object.values(app.releases);
const serviceContracts = Object.keys(release?.services ?? [])
.map((serviceName) => {
const service = release.services[serviceName];
const { contract } = service;
if (contract) {
try {
// Validate the contract syntax
validateContract(contract);

return {
serviceName,
contract,
optional: checkTruthy(
service.labels?.['io.balena.features.optional'],
),
};
} catch (e: any) {
throw new ContractValidationError(serviceName, e.message);
}
}
): Dictionary<AppContractResult> {
const result: Dictionary<AppContractResult> = {};

// Return a default contract for the service if no contract is defined
return { serviceName, contract: undefined, optional: false };
})
// map by serviceName
.reduce(
(contracts, { serviceName, ...serviceContract }) => ({
...contracts,
[serviceName]: serviceContract,
}),
{} as ServiceContracts,
);
for (const [appUuid, app] of Object.entries(apps)) {
const releases = Object.entries(app.releases);
if (releases.length === 0) {
continue;
}

if (Object.keys(serviceContracts).length > 0) {
// Validate service contracts if any
return [appUuid, containerContractsFulfilled(serviceContracts)];
}

// Return success if no services are found
return [
appUuid,
{
valid: true,
fulfilledServices: Object.keys(release?.services ?? []),
unmetAndOptional: [],
unmetServices: [],
},
];
})
.reduce(
(result, [appUuid, contractFulfilled]) => ({
...result,
[appUuid]: contractFulfilled,
}),
{} as Dictionary<ApplicationContractResult>,
// While app.releases is an object, we expect a target to only
// contain a single release per app so we use just the first element
const [commit, release] = releases[0];

const servicesWithContract = Object.entries(release.services ?? {}).map(
([serviceName, { contract, labels = {} }]) => {
if (contract) {
try {
validateContract(contract);
} catch (e: any) {
throw new ContractValidationError(serviceName, e.message);
}
}

return {
serviceName,
commit,
contract,
optional: checkTruthy(labels['io.balena.features.optional']),
};
},
);

result[appUuid] = containerContractsFulfilled(servicesWithContract);
}

return result;
}
Loading

0 comments on commit 48e526e

Please sign in to comment.