Merge pull request #2153 from balena-os/local-mode

Refactor state engine to be able to use current state as target
This commit is contained in:
flowzone-app[bot] 2023-04-21 23:03:37 +00:00 committed by GitHub
commit 48951d0333
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 364 additions and 293 deletions

View File

@ -40,7 +40,6 @@ export interface AppConstructOpts {
}
export interface UpdateState {
localMode: boolean;
availableImages: Image[];
containerIds: Dictionary<string>;
downloading: string[];
@ -190,7 +189,7 @@ export class App {
}
public async stepsToRemoveApp(
state: Omit<UpdateState, 'availableImages'>,
state: Omit<UpdateState, 'availableImages'> & { keepVolumes: boolean },
): Promise<CompositionStep[]> {
if (Object.keys(this.services).length > 0) {
return Object.values(this.services).map((service) =>
@ -203,7 +202,7 @@ export class App {
);
}
// Don't remove volumes in local mode
if (!state.localMode) {
if (!state.keepVolumes) {
if (Object.keys(this.volumes).length > 0) {
return Object.values(this.volumes).map((volume) =>
generateStep('removeVolume', { current: volume }),
@ -496,7 +495,6 @@ export class App {
private generateStepsForService(
{ current, target }: ChangingPair<Service>,
context: {
localMode: boolean;
availableImages: Image[];
downloading: string[];
targetApp: App;
@ -517,16 +515,12 @@ export class App {
return;
}
let needsDownload = false;
// don't attempt to fetch images whilst in local mode, as they should be there already
if (!context.localMode) {
needsDownload = !_.some(
context.availableImages,
(image) =>
image.dockerImageId === target?.config.image ||
imageManager.isSameImage(image, { name: target?.imageName! }),
);
}
const needsDownload = !_.some(
context.availableImages,
(image) =>
image.dockerImageId === target?.config.image ||
imageManager.isSameImage(image, { name: target?.imageName! }),
);
if (needsDownload && context.downloading.includes(target?.imageName!)) {
// The image needs to be downloaded, and it's currently downloading. We simply keep
@ -544,7 +538,6 @@ export class App {
context.targetApp,
needsDownload,
context.availableImages,
context.localMode,
context.networkPairs,
context.volumePairs,
context.servicePairs,
@ -578,7 +571,6 @@ export class App {
target,
context.targetApp,
context.availableImages,
context.localMode,
context.networkPairs,
context.volumePairs,
context.servicePairs,
@ -586,7 +578,6 @@ export class App {
const dependenciesMetForKill = this.dependenciesMetForServiceKill(
context.targetApp,
context.availableImages,
context.localMode,
);
return getStepsFromStrategy(strategy, {
@ -652,7 +643,6 @@ export class App {
targetApp: App,
needsDownload: boolean,
availableImages: Image[],
localMode: boolean,
networkPairs: Array<ChangingPair<Network>>,
volumePairs: Array<ChangingPair<Volume>>,
servicePairs: Array<ChangingPair<Service>>,
@ -668,7 +658,6 @@ export class App {
target,
targetApp,
availableImages,
localMode,
networkPairs,
volumePairs,
servicePairs,
@ -684,7 +673,6 @@ export class App {
target: Service,
targetApp: App,
availableImages: Image[],
localMode: boolean,
networkPairs: Array<ChangingPair<Network>>,
volumePairs: Array<ChangingPair<Volume>>,
servicePairs: Array<ChangingPair<Service>>,
@ -732,7 +720,7 @@ export class App {
}
// do not start until all images have been downloaded
return this.targetImagesReady(targetApp, availableImages, localMode);
return this.targetImagesReady(targetApp, availableImages);
}
// Unless the update strategy requires an early kill (i.e kill-then-download,
@ -742,24 +730,12 @@ export class App {
private dependenciesMetForServiceKill(
targetApp: App,
availableImages: Image[],
localMode: boolean,
) {
// Don't kill any services before all images have been downloaded
return this.targetImagesReady(targetApp, availableImages, localMode);
return this.targetImagesReady(targetApp, availableImages);
}
private targetImagesReady(
targetApp: App,
availableImages: Image[],
localMode: boolean,
) {
// because we only check for an image being available, in local mode this will always
// be the case, so return true regardless.
// If we ever unify image management betwen local and cloud mode, this will have to change
if (localMode) {
return true;
}
private targetImagesReady(targetApp: App, availableImages: Image[]) {
return targetApp.services.every((service) =>
availableImages.some(
(image) =>

View File

@ -57,13 +57,6 @@ const localModeManager = new LocalModeManager();
export let fetchesInProgress = 0;
export let timeSpentFetching = 0;
// In the case of intermediate target apply, toggle to true to avoid unintended image deletion
let isApplyingIntermediate = false;
export function setIsApplyingIntermediate(value: boolean = false) {
isApplyingIntermediate = value;
}
export function resetTimeSpentFetching(value: number = 0) {
timeSpentFetching = value;
}
@ -89,12 +82,6 @@ const actionExecutors = getExecutors({
export const validActions = Object.keys(actionExecutors);
// Volatile state for a single container. This is used for temporarily setting a
// different state for a container, such as running: false
let targetVolatilePerImageId: {
[imageId: number]: Partial<Service['config']>;
} = {};
export const initialized = _.once(async () => {
await config.initialized();
@ -129,17 +116,34 @@ function reportCurrentState(data?: Partial<InstancedAppState>) {
export async function getRequiredSteps(
currentApps: InstancedAppState,
targetApps: InstancedAppState,
ignoreImages: boolean = false,
keepImages?: boolean,
keepVolumes?: boolean,
): Promise<CompositionStep[]> {
// get some required data
const [downloading, availableImages] = await Promise.all([
imageManager.getDownloadingImageNames(),
imageManager.getAvailable(),
]);
const [downloading, availableImages, { localMode, delta }] =
await Promise.all([
imageManager.getDownloadingImageNames(),
imageManager.getAvailable(),
config.getMany(['localMode', 'delta']),
]);
const containerIdsByAppId = getAppContainerIds(currentApps);
// Local mode sets the image and volume retention only
// if not explicitely set by the caller
if (keepImages == null) {
keepImages = localMode;
}
if (keepVolumes == null) {
keepVolumes = localMode;
}
return await inferNextSteps(currentApps, targetApps, {
ignoreImages,
// Images are not removed while in local mode to avoid removing the user app images
keepImages,
// Volumes are not removed when stopping an app when going to local mode
keepVolumes,
delta,
downloading,
availableImages,
containerIdsByAppId,
@ -151,22 +155,14 @@ export async function inferNextSteps(
currentApps: InstancedAppState,
targetApps: InstancedAppState,
{
ignoreImages = false,
keepImages = false,
keepVolumes = false,
delta = true,
downloading = [] as string[],
availableImages = [] as Image[],
containerIdsByAppId = {} as { [appId: number]: Dictionary<string> },
} = {},
) {
// get some required data
const [{ localMode, delta }, cleanupNeeded] = await Promise.all([
config.getMany(['localMode', 'delta']),
imageManager.isCleanupNeeded(),
]);
if (localMode) {
ignoreImages = localMode;
}
const currentAppIds = Object.keys(currentApps).map((i) => parseInt(i, 10));
const targetAppIds = Object.keys(targetApps).map((i) => parseInt(i, 10));
@ -182,9 +178,9 @@ export async function inferNextSteps(
steps.push({ action: 'ensureSupervisorNetwork' });
}
} else {
if (!localMode && downloading.length === 0 && !isApplyingIntermediate) {
if (downloading.length === 0) {
// Avoid cleaning up dangling images while purging
if (cleanupNeeded) {
if (!keepImages && (await imageManager.isCleanupNeeded())) {
steps.push({ action: 'cleanup' });
}
@ -196,7 +192,7 @@ export async function inferNextSteps(
currentApps,
targetApps,
availableImages,
localMode,
keepImages,
),
);
}
@ -213,7 +209,6 @@ export async function inferNextSteps(
steps = steps.concat(
currentApps[id].nextStepsForAppUpdate(
{
localMode,
availableImages,
containerIds: containerIdsByAppId[id],
downloading,
@ -227,7 +222,7 @@ export async function inferNextSteps(
for (const id of onlyCurrent) {
steps = steps.concat(
await currentApps[id].stepsToRemoveApp({
localMode,
keepVolumes,
downloading,
containerIds: containerIdsByAppId[id],
}),
@ -250,7 +245,6 @@ export async function inferNextSteps(
steps = steps.concat(
emptyCurrent.nextStepsForAppUpdate(
{
localMode,
availableImages,
containerIds: containerIdsByAppId[id] ?? {},
downloading,
@ -263,7 +257,7 @@ export async function inferNextSteps(
}
const newDownloads = steps.filter((s) => s.action === 'fetch').length;
if (!ignoreImages && delta && newDownloads > 0) {
if (delta && newDownloads > 0) {
// Check that this is not the first pull for an
// application, as we want to download all images then
// Otherwise we want to limit the downloading of
@ -290,7 +284,7 @@ export async function inferNextSteps(
});
}
if (!ignoreImages && steps.length === 0 && downloading.length > 0) {
if (steps.length === 0 && downloading.length > 0) {
// We want to keep the state application alive
steps.push(generateStep('noop', {}));
}
@ -321,6 +315,8 @@ export async function getCurrentApps(): Promise<InstancedAppState> {
await volumeManager.getAll(),
);
const images = await imageManager.getState();
const apps: InstancedAppState = {};
for (const strAppId of Object.keys(componentGroups)) {
const appId = parseInt(strAppId, 10);
@ -346,12 +342,23 @@ export async function getCurrentApps(): Promise<InstancedAppState> {
!_.isEmpty(components.volumes) ||
!_.isEmpty(components.networks)
) {
const services = componentGroups[appId].services.map((s) => {
// We get the image metadata from the image database because we cannot
// get it from the container itself
const imageForService = images.find(
(img) => img.serviceName === s.serviceName && img.commit === s.commit,
);
s.imageName = imageForService?.name ?? s.imageName;
return s;
});
apps[appId] = new App(
{
appId,
appUuid: uuid,
commit,
services: componentGroups[appId].services,
services,
networks: componentGroups[appId].networks,
volumes: componentGroups[appId].volumes,
},
@ -480,7 +487,9 @@ function killServicesUsingApi(current: InstancedAppState): CompositionStep[] {
return steps;
}
// TODO: deprecate this method. Application changes should use intermediate targets
// this method is meant to be used only by device-state for applying the
// target state and not by other modules. Application changes should use
// intermediate targets to perform changes
export async function executeStep(
step: CompositionStep,
{ force = false, skipLock = false } = {},
@ -501,7 +510,6 @@ export async function executeStep(
} as any);
}
// FIXME: This shouldn't be in this module
export async function setTarget(
apps: TargetApps,
source: string,
@ -577,53 +585,13 @@ export async function setTarget(
promise = transaction((trx) => setInTransaction(filteredApps, trx));
}
await promise;
targetVolatilePerImageId = {};
if (!_.isEmpty(contractViolators)) {
throw new ContractViolationError(contractViolators);
}
}
export async function getTargetApps(): Promise<TargetApps> {
const apps = await dbFormat.getTargetJson();
// Whilst it may make sense here to return the target state generated from the
// internal instanced representation that we have, we make irreversable
// changes to the input target state to avoid having undefined entries into
// the instances throughout the supervisor. The target state is derived from
// the database entries anyway, so these two things should never be different
// (except for the volatile state)
//
_.each(apps, (app) =>
// There should only be a single release but is a simpler option
_.each(app.releases, (release) => {
if (!_.isEmpty(release.services)) {
release.services = _.mapValues(release.services, (svc) => {
if (svc.image_id && targetVolatilePerImageId[svc.image_id] != null) {
return { ...svc, ...targetVolatilePerImageId };
}
return svc;
});
}
}),
);
return apps;
}
export function setTargetVolatileForService(
imageId: number,
target: Partial<Service['config']>,
) {
if (targetVolatilePerImageId[imageId] == null) {
targetVolatilePerImageId = {};
}
targetVolatilePerImageId[imageId] = target;
}
export function clearTargetVolatileForServices(imageIds: number[]) {
for (const imageId of imageIds) {
targetVolatilePerImageId[imageId] = {};
}
return await dbFormat.getTargetJson();
}
/**
@ -683,7 +651,7 @@ function saveAndRemoveImages(
current: InstancedAppState,
target: InstancedAppState,
availableImages: imageManager.Image[],
localMode: boolean,
skipRemoval: boolean,
): CompositionStep[] {
type ImageWithoutID = Omit<imageManager.Image, 'dockerImageId' | 'id'>;
@ -744,9 +712,9 @@ function saveAndRemoveImages(
);
// Images that are available but we don't have them in the DB with the exact metadata:
let imagesToSave: imageManager.Image[] = [];
if (!localMode) {
imagesToSave = _.filter(targetImages, (targetImage) => {
const imagesToSave: imageManager.Image[] = _.filter(
targetImages,
(targetImage) => {
const isActuallyAvailable = _.some(availableImages, (availableImage) => {
// There is an image with same image name or digest
// on the database
@ -777,8 +745,8 @@ function saveAndRemoveImages(
(isActuallyAvailable && isNotSaved) ||
(!isActuallyAvailable && isAvailableOnTheEngine)
);
});
}
},
);
// Find images that will be be used as delta sources. Any existing image for the
// same app service is considered a delta source unless the target service has set
@ -798,9 +766,9 @@ function saveAndRemoveImages(
.map((img) => bestDeltaSource(img, availableImages))
.filter((img) => img != null);
const imagesToRemove = availableAndUnused.filter(
(image) => !deltaSources.includes(image.name),
);
const imagesToRemove = skipRemoval
? []
: availableAndUnused.filter((image) => !deltaSources.includes(image.name));
return imagesToSave
.map((image) => ({ action: 'saveImage', image } as CompositionStep))

View File

@ -258,10 +258,7 @@ export function getExecutors(app: {
await images.save(step.image);
},
cleanup: async () => {
const localMode = await config.get('localMode');
if (!localMode) {
await images.cleanup();
}
await images.cleanup();
},
createNetwork: async (step) => {
await networkManager.create(step.target);

View File

@ -24,7 +24,10 @@ export class Network {
private constructor() {}
private static deconstructDockerName(name: string): {
private static deconstructDockerName(
name: string,
appId?: number,
): {
name: string;
appId?: number;
appUuid?: string;
@ -41,7 +44,14 @@ export class Network {
return { name: matchWithAppUuid[2], appUuid };
}
const appId = parseInt(matchWithAppId[1], 10);
// If the appId is provided, then it was already available
// as a label, which means that the appUuid is the first match
// even if it is numeric only
if (appId != null && !isNaN(appId)) {
return { name: matchWithAppId[2], appUuid: matchWithAppId[1], appId };
}
appId = parseInt(matchWithAppId[1], 10);
if (isNaN(appId)) {
throw new InvalidNetworkNameError(name);
}
@ -55,12 +65,13 @@ export class Network {
public static fromDockerNetwork(network: NetworkInspectInfo): Network {
const ret = new Network();
const labels = network.Labels ?? {};
// Detect the name and appId from the inspect data
const { name, appId, appUuid } = Network.deconstructDockerName(
network.Name,
parseInt(labels['io.balena.app-id'], 10),
);
const labels = network.Labels ?? {};
if (!appId && isNaN(parseInt(labels['io.balena.app-id'], 10))) {
// This should never happen as supervised networks will always have either
// the id or the label

View File

@ -515,6 +515,7 @@ export class Service {
if (_.get(container, 'NetworkSettings.Networks', null) != null) {
networks = ComposeUtils.dockerNetworkToServiceNetwork(
container.NetworkSettings.Networks,
svc.containerId,
);
}
@ -1073,20 +1074,17 @@ export class Service {
if (current.aliases == null) {
sameNetwork = false;
} else {
// Take out the container id from both aliases, as it *will* be present
// in a currently running container, and can also be present in the target
// for example when doing a start-service
// Also sort the aliases, so we can do a simple comparison
const [currentAliases, targetAliases] = [
current.aliases,
target.aliases,
].map((aliases) =>
_.sortBy(
aliases.filter((a) => !_.startsWith(this.containerId || '', a)),
),
);
];
sameNetwork = _.isEqual(currentAliases, targetAliases);
// Docker may add keep old container ids as aliases for a specific service after
// restarts, this means that the target aliases really needs to be a subset of the
// current aliases to prevent service restarts when re-applying the same target state
sameNetwork =
_.intersection(currentAliases, targetAliases).length ===
targetAliases.length;
}
}
if (target.ipv4Address != null) {

View File

@ -496,6 +496,7 @@ export function serviceNetworksToDockerNetworks(
export function dockerNetworkToServiceNetwork(
dockerNetworks: Dockerode.ContainerInspectInfo['NetworkSettings']['Networks'],
containerId: string,
): ServiceConfig['networks'] {
// Take the input network object, filter out any nullish fields, extract things to
// the correct level and return
@ -504,7 +505,12 @@ export function dockerNetworkToServiceNetwork(
_.each(dockerNetworks, (net, name) => {
networks[name] = {};
if (net.Aliases != null && !_.isEmpty(net.Aliases)) {
networks[name].aliases = net.Aliases;
networks[name].aliases = net.Aliases.filter(
// Docker adds the container alias with the container id to the
// list. We don't want that alias to be part of the service config
// in case we want to re-use this service as target
(alias: string) => !containerId.startsWith(alias),
);
}
if (net.IPAMConfig != null) {
const ipam = net.IPAMConfig;

View File

@ -1,4 +1,3 @@
import * as Bluebird from 'bluebird';
import * as _ from 'lodash';
import { getGlobalApiKey, refreshKey } from '.';
@ -8,10 +7,7 @@ import * as deviceState from '../device-state';
import * as logger from '../logger';
import * as config from '../config';
import * as hostConfig from '../host-config';
import { App } from '../compose/app';
import * as applicationManager from '../compose/application-manager';
import * as serviceManager from '../compose/service-manager';
import * as volumeManager from '../compose/volume-manager';
import {
CompositionStepAction,
generateStep,
@ -29,8 +25,6 @@ import {
BadRequestError,
} from '../lib/errors';
import { InstancedDeviceState } from '../types';
/**
* Run an array of healthchecks, outputting whether all passed or not
* Used by:
@ -103,98 +97,27 @@ export const doRestart = async (appId: number, force: boolean = false) => {
`Application with ID ${appId} is not in the current state`,
);
}
const { services } = currentState.local.apps?.[appId];
applicationManager.clearTargetVolatileForServices(
services.map((svc) => svc.imageId),
);
const app = currentState.local.apps[appId];
const services = app.services;
app.services = [];
return deviceState.pausingApply(async () => {
for (const service of services) {
await serviceManager.kill(service, { wait: true });
await serviceManager.start(service);
}
});
return deviceState
.applyIntermediateTarget(currentState, {
skipLock: true,
})
.then(() => {
app.services = services;
return deviceState.applyIntermediateTarget(currentState, {
skipLock: true,
keepVolumes: false,
});
})
.finally(() => {
deviceState.triggerApplyTarget();
});
});
};
/**
* This doesn't truly return an InstancedDeviceState, but it's close enough to mostly work where it's used
*/
export function safeStateClone(
targetState: InstancedDeviceState,
): InstancedDeviceState {
// We avoid using cloneDeep here, as the class
// instances can cause a maximum call stack exceeded
// error
// TODO: This should really return the config as it
// is returned from the api, but currently that's not
// the easiest thing due to the way they are stored and
// retrieved from the db - when all of the application
// manager is strongly typed, revisit this. The best
// thing to do would be to represent the input with
// io-ts and make sure the below conforms to it
const cloned: DeepPartial<InstancedDeviceState> = {
local: {
config: {},
},
};
if (targetState.local != null) {
cloned.local = {
name: targetState.local.name,
config: _.cloneDeep(targetState.local.config),
apps: _.mapValues(targetState.local.apps, safeAppClone),
};
}
return cloned as InstancedDeviceState;
}
export function safeAppClone(app: App): App {
const containerIdForService = _.fromPairs(
_.map(app.services, (svc) => [
svc.serviceName,
svc.containerId != null ? svc.containerId.substring(0, 12) : '',
]),
);
return new App(
{
appId: app.appId,
appUuid: app.appUuid,
appName: app.appName,
commit: app.commit,
source: app.source,
services: app.services.map((svc) => {
// This is a bit of a hack, but when applying the target state as if it's
// the current state, this will include the previous containerId as a
// network alias. The container ID will be there as Docker adds it
// implicitly when creating a container. Here, we remove any previous
// container IDs before passing it back as target state. We have to do this
// here as when passing it back as target state, the service class cannot
// know that the alias being given is not in fact a user given one.
// TODO: Make the process of moving from a current state to a target state
// well-defined (and implemented in a separate module)
const svcCopy = _.cloneDeep(svc);
_.each(svcCopy.config.networks, (net) => {
if (Array.isArray(net.aliases)) {
net.aliases = net.aliases.filter(
(alias) => alias !== containerIdForService[svcCopy.serviceName],
);
}
});
return svcCopy;
}),
volumes: _.cloneDeep(app.volumes),
networks: _.cloneDeep(app.networks),
isHost: app.isHost,
},
true,
);
}
/**
* Purges volumes for an application.
* Used by:
@ -218,45 +141,25 @@ export const doPurge = async (appId: number, force: boolean = false) => {
);
}
const app = currentState.local.apps?.[appId];
/**
* With multi-container, Docker adds an invalid network alias equal to the current containerId
* to that service's network configs when starting a service. Thus when reapplying intermediateState
* after purging, use a cloned state instance which automatically filters out invalid network aliases.
* This will prevent error logs like the following:
* https://gist.github.com/cywang117/84f9cd4e6a9641dbed530c94e1172f1d#file-logs-sh-L58
*
* When networks do not match because of their aliases, services are killed and recreated
* an additional time which is unnecessary. Filtering prevents this additional restart BUT
* it is a stopgap measure until we can keep containerId network aliases from being stored
* in state's service config objects (TODO)
*/
const clonedState = safeStateClone(currentState);
// Set services & volumes as empty to be applied as intermediate state
app.services = [];
app.volumes = [];
const app = currentState.local.apps[appId];
applicationManager.setIsApplyingIntermediate(true);
// Delete the app from the current state
delete currentState.local.apps[appId];
return deviceState
.pausingApply(() =>
deviceState
.applyIntermediateTarget(currentState, { skipLock: true })
.then(() => {
// Explicitly remove volumes because application-manager won't
// remove any volumes that are part of an active application.
return Bluebird.each(volumeManager.getAllByAppId(appId), (vol) =>
vol.remove(),
);
})
.then(() => {
return deviceState.applyIntermediateTarget(clonedState, {
skipLock: true,
});
}),
)
.applyIntermediateTarget(currentState, {
skipLock: true,
// Purposely tell the apply function to delete volumes so they can get
// deleted even in local mode
keepVolumes: false,
})
.then(() => {
currentState.local.apps[appId] = app;
return deviceState.applyIntermediateTarget(currentState, {
skipLock: true,
});
})
.finally(() => {
applicationManager.setIsApplyingIntermediate(false);
deviceState.triggerApplyTarget();
});
})
@ -264,8 +167,6 @@ export const doPurge = async (appId: number, force: boolean = false) => {
logger.logSystemMessage('Purged data', { appId }, 'Purge data success'),
)
.catch((err) => {
applicationManager.setIsApplyingIntermediate(false);
logger.logSystemMessage(
`Error purging data: ${err}`,
{ appId, error: err },
@ -380,11 +281,6 @@ export const executeServiceAction = async ({
throw new NotFoundError(messages.targetServiceNotFound);
}
// Set volatile target state
applicationManager.setTargetVolatileForService(currentService.imageId, {
running: action !== 'stop',
});
// Execute action on service
return await executeDeviceAction(
generateStep(action, {

View File

@ -291,8 +291,7 @@ router.get(
);
router.get('/v2/local/target-state', async (_req, res) => {
const targetState = await deviceState.getTarget();
const target = actions.safeStateClone(targetState);
const target = await deviceState.getTarget();
res.status(200).json({
status: 'success',

View File

@ -555,6 +555,8 @@ export async function shutdown({
});
}
// FIXME: this method should not be exported, all target state changes
// should happen via intermediate targets
export async function executeStepAction(
step: DeviceStateStep<PossibleStepTargets>,
{
@ -671,6 +673,7 @@ export const applyTarget = async ({
skipLock = false,
nextDelay = 200,
retryCount = 0,
keepVolumes = undefined as boolean | undefined,
} = {}) => {
if (!intermediate) {
await applyBlocker;
@ -701,6 +704,11 @@ export const applyTarget = async ({
const appSteps = await applicationManager.getRequiredSteps(
currentState.local.apps,
targetState.local.apps,
// Do not remove images while applying an intermediate state
// if not applying intermediate, we let getRequired steps set
// the value
intermediate || undefined,
keepVolumes,
);
if (_.isEmpty(appSteps)) {
@ -758,6 +766,7 @@ export const applyTarget = async ({
skipLock,
nextDelay,
retryCount,
keepVolumes,
});
} catch (e: any) {
if (e instanceof UpdatesLockedError) {
@ -776,7 +785,7 @@ export const applyTarget = async ({
});
};
export function pausingApply(fn: () => any) {
function pausingApply(fn: () => any) {
const lock = () => {
return writeLock('pause').disposer((release) => release());
};
@ -858,13 +867,24 @@ export function triggerApplyTarget({
return null;
}
export function applyIntermediateTarget(
export async function applyIntermediateTarget(
intermediate: InstancedDeviceState,
{ force = false, skipLock = false } = {},
{
force = false,
skipLock = false,
keepVolumes = undefined as boolean | undefined,
} = {},
) {
// TODO: Make sure we don't accidentally overwrite this
intermediateTarget = intermediate;
return applyTarget({ intermediate: true, force, skipLock }).then(() => {
intermediateTarget = null;
return pausingApply(async () => {
// TODO: Make sure we don't accidentally overwrite this
intermediateTarget = intermediate;
return applyTarget({
intermediate: true,
force,
skipLock,
keepVolumes,
}).then(() => {
intermediateTarget = null;
});
});
}

View File

@ -705,6 +705,30 @@ describe('compose/application-manager', () => {
expect(steps.filter((s) => s.action === 'removeVolume')).to.not.be.empty;
});
it('should remove volumes from previous applications except if keepVolumes is set', async () => {
const targetApps = createApps({ networks: [DEFAULT_NETWORK] }, true);
const { currentApps, availableImages, downloading, containerIdsByAppId } =
createCurrentState({
services: [],
networks: [],
// Volume with different id
volumes: [Volume.fromComposeObject('test-volume', 2, 'deadbeef')],
});
const steps = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
keepVolumes: true,
downloading,
availableImages,
containerIdsByAppId,
},
);
expect(steps.filter((s) => s.action === 'removeVolume')).to.be.empty;
});
it('should infer that we need to create the supervisor network if it does not exist', async () => {
const docker = new Docker();
await docker.getNetwork('supervisor0').remove();
@ -874,6 +898,54 @@ describe('compose/application-manager', () => {
.that.deep.includes({ name: 'old-image' });
});
it('should infer that an image should be removed if it is no longer referenced in current or target state (only target) unless keepImages is true', async () => {
const targetApps = createApps(
{
services: [
await createService(
{ image: 'main-image' },
// Target has a matching image already
{ options: { imageInfo: { Id: 'sha256:bbbb' } } },
),
],
networks: [DEFAULT_NETWORK],
},
true,
);
const { currentApps, availableImages, downloading, containerIdsByAppId } =
createCurrentState({
services: [],
networks: [DEFAULT_NETWORK],
images: [
// An image for a service that no longer exists
createImage({
name: 'old-image',
appId: 5,
serviceName: 'old-service',
dockerImageId: 'sha256:aaaa',
}),
createImage({
name: 'main-image',
appId: 1,
serviceName: 'main',
dockerImageId: 'sha256:bbbb',
}),
],
});
const steps = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
keepImages: true,
downloading,
availableImages,
containerIdsByAppId,
},
);
expect(steps.filter((s) => s.action === 'removeImage')).to.be.empty;
});
it('should infer that an image should be removed if it is no longer referenced in current or target state (only current)', async () => {
const targetApps = createApps(
{
@ -928,6 +1000,54 @@ describe('compose/application-manager', () => {
.that.deep.includes({ name: 'old-image' });
});
it('should infer that an image should be removed if it is no longer referenced in current or target state (only current) unless keepImages is true', async () => {
const targetApps = createApps(
{
services: [],
networks: [DEFAULT_NETWORK],
},
true,
);
const { currentApps, availableImages, downloading, containerIdsByAppId } =
createCurrentState({
services: [
await createService(
{ image: 'main-image' },
// Target has a matching image already
{ options: { imageInfo: { Id: 'sha256:bbbb' } } },
),
],
networks: [DEFAULT_NETWORK],
images: [
// An image for a service that no longer exists
createImage({
name: 'old-image',
appId: 5,
serviceName: 'old-service',
dockerImageId: 'sha256:aaaa',
}),
createImage({
name: 'main-image',
appId: 1,
serviceName: 'main',
dockerImageId: 'sha256:bbbb',
}),
],
});
const steps = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
keepImages: true,
downloading,
availableImages,
containerIdsByAppId,
},
);
expect(steps.filter((s) => s.action === 'removeImage')).to.be.empty;
});
it('should infer that an image should be saved if it is not in the available image list but it can be found on disk', async () => {
const targetApps = createApps(
{

View File

@ -237,7 +237,10 @@ describe('manages application lifecycle', () => {
containers.map((ctn) => ctn.State.StartedAt),
);
await actions.doRestart(APP_ID);
await request(BALENA_SUPERVISOR_ADDRESS)
.post(`/v1/restart`)
.set('Content-Type', 'application/json')
.send(JSON.stringify({ appId: APP_ID }));
const restartedContainers = await waitForSetup(
targetState,
@ -503,7 +506,9 @@ describe('manages application lifecycle', () => {
containers.map((ctn) => ctn.State.StartedAt),
);
await actions.doRestart(APP_ID);
await request(BALENA_SUPERVISOR_ADDRESS)
.post(`/v2/applications/${APP_ID}/restart`)
.set('Content-Type', 'application/json');
const restartedContainers = await waitForSetup(
targetState,

View File

@ -11,7 +11,7 @@ import { ServiceComposeConfig } from '~/src/compose/types/service';
import Volume from '~/src/compose/volume';
const defaultContext = {
localMode: false,
keepVolumes: false,
availableImages: [] as Image[],
containerIds: {},
downloading: [] as string[],

View File

@ -574,6 +574,80 @@ describe('compose/service: unit tests', () => {
expect(svc1.isEqualConfig(svc2, {})).to.be.true;
});
});
it('should accept that target network aliases are a subset of current network aliases', async () => {
const svc1 = await Service.fromComposeObject(
{
appId: 1,
serviceId: 1,
serviceName: 'test',
composition: {
networks: {
test: {
aliases: ['hello', 'world'],
},
},
},
},
{ appName: 'test' } as any,
);
const svc2 = await Service.fromComposeObject(
{
appId: 1,
serviceId: 1,
serviceName: 'test',
composition: {
networks: {
test: {
aliases: ['hello', 'sweet', 'world'],
},
},
},
},
{ appName: 'test' } as any,
);
// All aliases in target service (svc1) are contained in service 2
expect(svc2.isEqualConfig(svc1, {})).to.be.true;
// But the opposite is not true
expect(svc1.isEqualConfig(svc2, {})).to.be.false;
});
it('should accept equal lists of network aliases', async () => {
const svc1 = await Service.fromComposeObject(
{
appId: 1,
serviceId: 1,
serviceName: 'test',
composition: {
networks: {
test: {
aliases: ['hello', 'world'],
},
},
},
},
{ appName: 'test' } as any,
);
const svc2 = await Service.fromComposeObject(
{
appId: 1,
serviceId: 1,
serviceName: 'test',
composition: {
networks: {
test: {
aliases: ['hello', 'world'],
},
},
},
},
{ appName: 'test' } as any,
);
expect(svc1.isEqualConfig(svc2, {})).to.be.true;
expect(svc2.isEqualConfig(svc1, {})).to.be.true;
});
});
describe('Feature labels', () => {
@ -895,7 +969,7 @@ describe('compose/service: unit tests', () => {
IPv6Address: '5.6.7.8',
LinkLocalIps: ['123.123.123'],
},
Aliases: ['test', '1123'],
Aliases: ['test', '1123', 'deadbeef'],
},
}).config.networks,
).to.deep.equal({
@ -903,6 +977,7 @@ describe('compose/service: unit tests', () => {
ipv4Address: '1.2.3.4',
ipv6Address: '5.6.7.8',
linkLocalIps: ['123.123.123'],
// The container id got removed from the alias list
aliases: ['test', '1123'],
},
});