Store rejected apps in the database

This moves from throwing an error when an app is rejected due to unmet
requirements (because of contracts) to storing the target with a
`rejected` flag on the database.

The application manager filters rejected apps when calculating steps to
prevent them from affecting the current state. The state engine uses the
rejection info to generate the state report.

Change-type: minor
This commit is contained in:
Felipe Lalanne
2024-06-27 11:53:24 -04:00
parent 227fee9941
commit e9a52e6786
12 changed files with 336 additions and 120 deletions

View File

@ -38,6 +38,7 @@ export interface AppConstructOpts {
commit?: string;
source?: string;
isHost?: boolean;
isRejected?: boolean;
services: Service[];
volumes: Volume[];
@ -57,6 +58,7 @@ class AppImpl implements App {
public commit?: string;
public source?: string;
public isHost?: boolean;
public isRejected?: boolean;
// Services are stored as an array, as at any one time we could have more than one
// service for a single service ID running (for example handover)
public services: Service[];
@ -77,6 +79,10 @@ class AppImpl implements App {
this.networks = opts.networks;
this.isHost = !!opts.isHost;
if (isTargetState) {
this.isRejected = !!opts.isRejected;
}
if (
this.networks.find((n) => n.name === 'default') == null &&
isTargetState
@ -1054,6 +1060,7 @@ class AppImpl implements App {
appName: app.name,
source: app.source,
isHost: app.isHost,
isRejected: app.rejected,
services,
volumes,
networks,

View File

@ -191,8 +191,19 @@ export async function inferNextSteps(
// We want to remove images before moving on to anything else
if (steps.length === 0) {
const targetAndCurrent = _.intersection(currentAppIds, targetAppIds);
const onlyTarget = _.difference(targetAppIds, currentAppIds);
// We only want to modify existing apps for accepted targets
const acceptedTargetAppIds = targetAppIds.filter(
(id) => !targetApps[id].isRejected,
);
const targetAndCurrent = _.intersection(
currentAppIds,
acceptedTargetAppIds,
);
const onlyTarget = _.difference(acceptedTargetAppIds, currentAppIds);
// We do not want to remove rejected apps, so we compare with the
// original target id list
const onlyCurrent = _.difference(currentAppIds, targetAppIds);
// For apps that exist in both current and target state, calculate what we need to
@ -502,34 +513,27 @@ export async function setTarget(
trx: Transaction,
) {
const setInTransaction = async (
$filteredApps: TargetApps,
$apps: TargetApps,
$rejectedApps: string[],
$trx: Transaction,
) => {
await dbFormat.setApps($filteredApps, source, $trx);
await dbFormat.setApps($apps, source, $rejectedApps, $trx);
await $trx('app')
.where({ source })
.whereNotIn(
'appId',
// Use apps here, rather than filteredApps, to
// avoid removing a release from the database
// without an application to replace it.
// Currently this will only happen if the release
// which would replace it fails a contract
// validation check
Object.values(apps).map(({ id: appId }) => appId),
// Delete every appId not in the target list
Object.values($apps).map(({ id: appId }) => appId),
)
.del();
};
// We look at the container contracts here, as if we
// cannot run the release, we don't want it to be added
// to the database, overwriting the current release. This
// is because if we just reject the release, but leave it
// in the db, if for any reason the current state stops
// running, we won't restart it, leaving the device
// 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
// We look at the container contracts here, apps with failing contract requirements
// are stored in the database with a `rejected: true property`, which tells
// the inferNextSteps function to ignore them when making changes.
//
// Apps with optional services with unmet requirements are stored as
// `rejected: false`, but services with unmet requirements are removed
const contractViolators: contracts.ContractViolators = {};
const fulfilledContracts = contracts.validateTargetContracts(apps);
const filteredApps = structuredClone(apps);
@ -538,14 +542,13 @@ export async function setTarget(
{ valid, unmetServices, unmetAndOptional },
] of Object.entries(fulfilledContracts)) {
if (!valid) {
// Add the app to the list of contract violators to generate a system
// error
contractViolators[appUuid] = {
appId: apps[appUuid].id,
appName: apps[appUuid].name,
services: unmetServices.map(({ serviceName }) => serviceName),
};
// 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
@ -563,17 +566,22 @@ export async function setTarget(
}
}
await setInTransaction(filteredApps, trx);
let rejectedApps: string[] = [];
if (!_.isEmpty(contractViolators)) {
// TODO: add rejected state for contract violator apps
throw new contracts.ContractViolationError(contractViolators);
rejectedApps = Object.keys(contractViolators);
reportRejectedReleases(contractViolators);
}
await setInTransaction(filteredApps, rejectedApps, trx);
}
export async function getTargetApps(): Promise<TargetApps> {
return await dbFormat.getTargetJson();
}
export async function getTargetAppsWithRejections() {
return await dbFormat.getTargetWithRejections();
}
/**
* This is only used by the API. Do not use as the use of serviceIds is getting
* deprecated
@ -778,12 +786,19 @@ function reportOptionalContainers(serviceNames: string[]) {
'. ',
)}`;
log.info(message);
return logger.logSystemMessage(
message,
{},
'optionalContainerViolation',
true,
logger.logSystemMessage(message, {});
}
function reportRejectedReleases(violators: contracts.ContractViolators) {
const appStrings = Object.values(violators).map(
({ appName, services }) =>
`${appName}: Services with unmet requirements: ${services.join(', ')}`,
);
const message = `Some releases were rejected due to having unmet requirements:\n ${appStrings.join(
'\n ',
)}`;
log.error(message);
logger.logSystemMessage(message, { error: true });
}
/**

View File

@ -21,6 +21,7 @@ export interface App {
commit?: string;
source?: string;
isHost?: boolean;
isRejected?: boolean;
// Services are stored as an array, as at any one time we could have more than one
// service for a single service ID running (for example handover)
services: Service[];

View File

@ -1,3 +1,5 @@
import type { App } from './app';
export type InstancedAppState = { [appId: number]: App };
export type AppRelease = { appUuid: string; releaseUuid: string };