balena-supervisor/test/lib/state-helper.ts
Felipe Lalanne e9a52e6786 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
2024-08-30 10:52:11 -04:00

206 lines
4.6 KiB
TypeScript

import { App } from '~/src/compose/app';
import * as imageManager from '~/src/compose/images';
import type { Image } from '~/src/compose/images';
import { Network } from '~/src/compose/network';
import { Service } from '~/src/compose/service';
import type { ServiceComposeConfig } from '~/src/compose/types/service';
import type { Volume } from '~/src/compose/volume';
import type {
CompositionStep,
CompositionStepAction,
} from '~/src/compose/composition-steps';
import type { InstancedAppState } from '~/src/compose/types';
export const DEFAULT_NETWORK = Network.fromComposeObject(
'default',
1,
'appuuid',
{},
);
export async function createService(
{
appId = 1,
appUuid = 'appuuid',
serviceName = 'main',
commit = 'main-commit',
...conf
} = {} as Partial<ServiceComposeConfig>,
{ state = {} as Partial<Service>, options = {} as any } = {},
) {
const svc = await Service.fromComposeObject(
{
appId,
appUuid,
serviceName,
commit,
// db ids should not be used for target state calculation, but images
// are compared using _.isEqual so leaving this here to have image comparisons
// match
serviceId: 1,
imageId: 1,
releaseId: 1,
...conf,
},
options,
);
// Add additonal configuration
for (const k of Object.keys(state)) {
(svc as any)[k] = (state as any)[k];
}
return svc;
}
export function createImage(
{
appId = 1,
appUuid = 'appuuid',
name = 'test-image',
serviceName = 'main',
commit = 'main-commit',
...extra
} = {} as Partial<Image>,
) {
return {
appId,
appUuid,
name,
serviceName,
commit,
// db ids should not be used for target state calculation, but images
// are compared using _.isEqual so leaving this here to have image comparisons
// match
imageId: 1,
releaseId: 1,
serviceId: 1,
...extra,
} as Image;
}
export function createApp({
services = [] as Service[],
networks = [] as Network[],
volumes = [] as Volume[],
isTarget = false,
appId = 1,
appUuid = 'appuuid',
isRejected = false,
} = {}) {
return new App(
{
appId,
appUuid,
services,
networks,
volumes,
isRejected,
},
isTarget,
);
}
export function createApps(
{
services = [] as Service[],
networks = [] as Network[],
volumes = [] as Volume[],
rejectedAppIds = [] as number[],
},
target = false,
) {
const servicesByAppId = services.reduce(
(svcs, s) => ({ ...svcs, [s.appId]: [s].concat(svcs[s.appId] || []) }),
{} as Dictionary<Service[]>,
);
const volumesByAppId = volumes.reduce(
(vols, v) => ({ ...vols, [v.appId]: [v].concat(vols[v.appId] || []) }),
{} as Dictionary<Volume[]>,
);
const networksByAppId = networks.reduce(
(nets, n) => ({ ...nets, [n.appId]: [n].concat(nets[n.appId] || []) }),
{} as Dictionary<Network[]>,
);
const allAppIds = [
...new Set([
...Object.keys(servicesByAppId),
...Object.keys(networksByAppId),
...Object.keys(volumesByAppId),
]),
].map((i) => parseInt(i, 10));
const apps: InstancedAppState = {};
for (const appId of allAppIds) {
const isRejected = rejectedAppIds.includes(appId);
apps[appId] = createApp({
services: servicesByAppId[appId] ?? [],
networks: networksByAppId[appId] ?? [],
volumes: volumesByAppId[appId] ?? [],
appId,
appUuid: servicesByAppId[appId]?.[0]?.appUuid ?? 'deadbeef',
isTarget: target,
isRejected,
});
}
return apps;
}
export function createCurrentState({
services = [] as Service[],
networks = [] as Network[],
volumes = [] as Volume[],
images = services.map((s) => ({
// Infer images from services by default
dockerImageId: s.dockerImageId,
...imageManager.imageFromService(s),
})) as Image[],
downloading = [] as string[],
}) {
const currentApps = createApps({ services, networks, volumes });
const containerIdsByAppId = services.reduce(
(ids, s) => ({
...ids,
[s.appId]: {
...ids[s.appId],
...(s.serviceName &&
s.containerId && { [s.serviceName]: s.containerId }),
},
}),
{} as { [appId: number]: Dictionary<string> },
);
return {
currentApps,
availableImages: images,
downloading,
containerIdsByAppId,
};
}
export const expectSteps = (
action: CompositionStepAction,
steps: CompositionStep[],
min = 1,
max = min,
message = `Expected to find ${min} step(s) with action '${action}', instead found ${JSON.stringify(
steps.map((s) => s.action),
)}`,
) => {
const filtered = steps.filter((s) => s.action === action);
if (filtered.length < min || filtered.length > max) {
throw new Error(message);
}
return filtered;
};
export function expectNoStep(
action: CompositionStepAction,
steps: CompositionStep[],
) {
expectSteps(action, steps, 0, 0);
}