Add takeLock to state funnel

A takeLock step should be generated before any of the following steps:
* kill
* start
* stop
* updateMetadata
* restart
* handover

ALL services in an app will be locked for any of the above actions,
unless the action is generated through Supervisor API's
`POST /v2/applications/:appId/(start|stop|restart)-service` endpoints,
in which case only the target service will be locked.

A lock will be taken for a service before it starts by creating the
directory in /tmp before the Engine creates it through bind mounts.

Also, the commit simplifies the generation of service kill
steps from network/volume changes or removals.

Signed-off-by: Christina Ying Wang <christina@balena.io>
This commit is contained in:
Christina Ying Wang 2024-03-06 00:08:46 -08:00
parent cf8d8cedd7
commit 10f294cf8e
4 changed files with 1416 additions and 206 deletions

View File

@ -53,6 +53,10 @@ interface ChangingPair<T> {
target?: T;
}
export interface AppsToLockMap {
[appId: number]: Set<string>;
}
export class App {
public appId: number;
public appUuid?: string;
@ -132,39 +136,60 @@ export class App {
true,
);
const { removePairs, installPairs, updatePairs } = this.compareServices(
this.services,
target.services,
state.containerIds,
);
const { removePairs, installPairs, updatePairs, dependentServices } =
this.compareServices(
this.services,
target.services,
state.containerIds,
networkChanges,
volumeChanges,
);
// For every service which needs to be updated, update via update strategy.
const servicePairs = removePairs.concat(updatePairs, installPairs);
steps = steps.concat(
servicePairs
.map((pair) =>
this.generateStepsForService(pair, {
...state,
servicePairs,
targetApp: target,
networkPairs: networkChanges,
volumePairs: volumeChanges,
// generateStepsForService will populate appsToLock with services that
// need to be locked, including services that need to be removed due to
// network or volume changes.
const appsToLock: AppsToLockMap = {
// this.appId should always equal target.appId.
[target.appId]: new Set<string>(),
};
const serviceSteps = servicePairs
.flatMap((pair) =>
this.generateStepsForService(pair, {
...state,
servicePairs,
targetApp: target,
networkPairs: networkChanges,
volumePairs: volumeChanges,
appsToLock,
}),
)
.filter((step) => step != null);
// Generate lock steps from appsToLock
for (const [appId, services] of Object.entries(appsToLock)) {
if (services.size > 0) {
steps.push(
generateStep('takeLock', {
appId: parseInt(appId, 10),
services: Array.from(services),
force: state.force,
}),
)
.filter((step) => step != null) as CompositionStep[],
);
);
}
}
// Attach service steps
steps = steps.concat(serviceSteps);
// Generate volume steps
steps = steps.concat(
this.generateStepsForComponent(volumeChanges, servicePairs, (v, svc) =>
svc.hasVolume(v.name),
),
this.generateStepsForComponent(volumeChanges, dependentServices),
);
// Generate network steps
steps = steps.concat(
this.generateStepsForComponent(networkChanges, servicePairs, (n, svc) =>
svc.hasNetwork(n.name),
),
this.generateStepsForComponent(networkChanges, dependentServices),
);
if (steps.length === 0) {
@ -176,14 +201,17 @@ export class App {
appId: this.appId,
}),
);
} else if (
target.services.length > 0 &&
target.services.some(({ appId, serviceName }) =>
state.locksTaken.isLocked(appId, serviceName),
}
// Current & target should be the same appId, but one of either current
// or target may not have any services, so we need to check both
const allServices = this.services.concat(target.services);
if (
allServices.length > 0 &&
allServices.some((s) =>
state.locksTaken.isLocked(s.appId, s.serviceName),
)
) {
// Release locks for current services before settling state.
// Current services should be the same as target services at this point.
// Release locks for all services before settling state
steps.push(
generateStep('releaseLock', {
appId: target.appId,
@ -198,6 +226,21 @@ export class App {
state: Omit<UpdateState, 'availableImages'> & { keepVolumes: boolean },
): CompositionStep[] {
if (Object.keys(this.services).length > 0) {
// Take all locks before killing
if (
this.services.some(
(svc) => !state.locksTaken.isLocked(svc.appId, svc.serviceName),
)
) {
return [
generateStep('takeLock', {
appId: this.appId,
services: this.services.map((svc) => svc.serviceName),
force: state.force,
}),
];
}
return Object.values(this.services).map((service) =>
generateStep('kill', { current: service }),
);
@ -302,14 +345,24 @@ export class App {
return outputs;
}
private getDependentServices<T extends Volume | Network>(
component: T,
dependencyFn: (component: T, service: Service) => boolean,
) {
return this.services.filter((s) => dependencyFn(component, s));
}
private compareServices(
current: Service[],
target: Service[],
containerIds: UpdateState['containerIds'],
networkChanges: Array<ChangingPair<Network>>,
volumeChanges: Array<ChangingPair<Volume>>,
): {
installPairs: Array<ChangingPair<Service>>;
removePairs: Array<ChangingPair<Service>>;
updatePairs: Array<ChangingPair<Service>>;
dependentServices: Service[];
} {
const currentByServiceName = _.keyBy(current, 'serviceName');
const targetByServiceName = _.keyBy(target, 'serviceName');
@ -317,8 +370,26 @@ export class App {
const currentServiceNames = Object.keys(currentByServiceName);
const targetServiceNames = Object.keys(targetByServiceName);
// For volume|network removal or config changes, we require dependent
// services be killed first.
const dependentServices: Service[] = [];
for (const { current: c } of networkChanges) {
if (c != null) {
dependentServices.push(
...this.getDependentServices(c, (n, svc) => svc.hasNetwork(n.name)),
);
}
}
for (const { current: c } of volumeChanges) {
if (c != null) {
dependentServices.push(
...this.getDependentServices(c, (v, svc) => svc.hasVolume(v.name)),
);
}
}
const toBeRemoved = _(currentServiceNames)
.difference(targetServiceNames)
.union(dependentServices.map((s) => s.serviceName))
.map((id) => ({ current: currentByServiceName[id] }))
.value();
@ -426,6 +497,15 @@ export class App {
);
};
/**
* Checks if a service is destined for removal due to a network or volume change
*/
const shouldBeRemoved = (serviceCurrent: Service) => {
return toBeRemoved.some(
(pair) => pair.current.serviceName === serviceCurrent.serviceName,
);
};
/**
* Filter all the services which should be updated due to run state change, or config mismatch.
*/
@ -436,16 +516,18 @@ export class App {
}))
.filter(
({ current: c, target: t }) =>
!isEqualExceptForRunningState(c, t) ||
shouldBeStarted(c, t) ||
shouldBeStopped(c, t) ||
shouldWaitForStop(c),
!shouldBeRemoved(c) &&
(!isEqualExceptForRunningState(c, t) ||
shouldBeStarted(c, t) ||
shouldBeStopped(c, t) ||
shouldWaitForStop(c)),
);
return {
installPairs: toBeInstalled,
removePairs: toBeRemoved,
updatePairs: toBeUpdated,
dependentServices,
};
}
@ -457,8 +539,7 @@ export class App {
// it should be changed.
private generateStepsForComponent<T extends Volume | Network>(
components: Array<ChangingPair<T>>,
changingServices: Array<ChangingPair<Service>>,
dependencyFn: (component: T, service: Service) => boolean,
dependentServices: Service[],
): CompositionStep[] {
if (components.length === 0) {
return [];
@ -466,36 +547,42 @@ export class App {
let steps: CompositionStep[] = [];
const componentIsVolume =
(components[0].current ?? components[0].target) instanceof Volume;
const actions: {
create: CompositionStepAction;
remove: CompositionStepAction;
} =
(components[0].current ?? components[0].target) instanceof Volume
? { create: 'createVolume', remove: 'removeVolume' }
: { create: 'createNetwork', remove: 'removeNetwork' };
} = componentIsVolume
? { create: 'createVolume', remove: 'removeVolume' }
: { create: 'createNetwork', remove: 'removeNetwork' };
for (const { current, target } of components) {
// If a current exists, we're either removing it or updating the configuration. In
// both cases, we must remove the component first, so we output those steps first.
// both cases, we must remove the component before creating it to avoid
// Engine conflicts. So we always emit a remove step first.
// If we do remove the component, we first need to remove any services which depend
// on the component
// on the component. The service removal steps are generated in this.generateStepsForService
// after their removal is calculated in this.compareServices.
if (current != null) {
// Find any services which are currently running which need to be killed when we
// recreate this component
const dependencies = _.filter(this.services, (s) =>
dependencyFn(current, s),
);
if (dependencies.length > 0) {
// We emit kill steps for these services, and wait to destroy the component in
// the next state application loop
// FIXME: We should add to the changingServices array, as we could emit several
// kill steps for a service
steps = steps.concat(
dependencies.flatMap((svc) =>
this.generateKillStep(svc, changingServices),
),
);
} else {
// If there are any dependent services which have the volume or network,
// we cannot proceed to component removal.
const dependentServicesOfComponent = dependentServices.filter((s) => {
if (componentIsVolume) {
return this.serviceHasNetworkOrVolume(
s,
[],
[{ current: current as Volume, target: target as Volume }],
);
} else {
return this.serviceHasNetworkOrVolume(
s,
[{ current: current as Network, target: target as Network }],
[],
);
}
});
if (dependentServicesOfComponent.length === 0) {
steps = steps.concat([generateStep(actions.remove, { current })]);
}
} else if (target != null) {
@ -513,17 +600,21 @@ export class App {
networkPairs: Array<ChangingPair<Network>>;
volumePairs: Array<ChangingPair<Volume>>;
servicePairs: Array<ChangingPair<Service>>;
appsToLock: AppsToLockMap;
} & UpdateState,
): Nullable<CompositionStep> {
): CompositionStep[] {
const servicesLocked = this.services
.concat(context.targetApp.services)
.every((svc) => context.locksTaken.isLocked(svc.appId, svc.serviceName));
if (current?.status === 'Stopping') {
// There's a kill step happening already, emit a noop to ensure
// we stay alive while this happens
return generateStep('noop', {});
return [generateStep('noop', {})];
}
if (current?.status === 'Dead') {
// A remove step will already have been generated, so we let the state
// application loop revisit this service, once the state has settled
return;
return [];
}
const needsDownload =
@ -540,7 +631,7 @@ export class App {
) {
// The image needs to be downloaded, and it's currently downloading.
// We simply keep the application loop alive
return generateStep('noop', {});
return [generateStep('noop', {})];
}
if (current == null) {
@ -549,6 +640,8 @@ export class App {
target!,
context.targetApp,
needsDownload,
servicesLocked,
context.appsToLock,
context.availableImages,
context.networkPairs,
context.volumePairs,
@ -573,8 +666,9 @@ export class App {
return this.generateContainerStep(
current,
target,
context.locksTaken,
context.force,
context.appsToLock,
context.targetApp.services,
servicesLocked,
);
}
@ -607,29 +701,13 @@ export class App {
dependenciesMetForStart,
dependenciesMetForKill,
needsSpecialKill,
servicesLocked,
services: this.services.concat(context.targetApp.services),
appsToLock: context.appsToLock,
});
}
}
// We return an array from this function so the caller can just concatenate the arrays
// without worrying if the step is skipped or not
private generateKillStep(
service: Service,
changingServices: Array<ChangingPair<Service>>,
): CompositionStep[] {
if (
service.status !== 'Stopping' &&
!_.some(
changingServices,
({ current }) => current?.serviceName === service.serviceName,
)
) {
return [generateStep('kill', { current: service })];
} else {
return [];
}
}
private serviceHasNetworkOrVolume(
svc: Service,
networkPairs: Array<ChangingPair<Network>>,
@ -667,31 +745,36 @@ export class App {
private generateContainerStep(
current: Service,
target: Service,
locksTaken: LocksTakenMap,
force: boolean,
) {
appsToLock: AppsToLockMap,
targetServices: Service[],
servicesLocked: boolean,
): CompositionStep[] {
// Update container metadata if service release has changed
if (current.commit !== target.commit) {
// QUESTION: Should updateMetadata only be allowed when
// *all* services have locks taken by the Supervisor? Currently
// it proceeds when the service it's updating has locks taken,
// meaning the service could be on new release while another service
// with a user-taken lock is still on old release.
if (locksTaken.isLocked(target.appId, target.serviceName)) {
return generateStep('updateMetadata', { current, target });
}
// Otherwise, take lock for service first
return generateStep('takeLock', {
appId: target.appId,
services: [target.serviceName],
force,
});
} else if (target.config.running !== current.config.running) {
if (target.config.running) {
return generateStep('start', { target });
if (servicesLocked) {
return [generateStep('updateMetadata', { current, target })];
} else {
return generateStep('stop', { current });
// Otherwise, take lock for all services first
this.services.concat(targetServices).forEach((s) => {
appsToLock[target.appId].add(s.serviceName);
});
return [];
}
} else if (target.config.running !== current.config.running) {
// Take lock for all services before starting/stopping container
if (!servicesLocked) {
this.services.concat(targetServices).forEach((s) => {
appsToLock[target.appId].add(s.serviceName);
});
return [];
}
if (target.config.running) {
return [generateStep('start', { target })];
} else {
return [generateStep('stop', { current })];
}
} else {
return [];
}
}
@ -699,21 +782,26 @@ export class App {
target: Service,
targetApp: App,
needsDownload: boolean,
servicesLocked: boolean,
appsToLock: AppsToLockMap,
availableImages: UpdateState['availableImages'],
networkPairs: Array<ChangingPair<Network>>,
volumePairs: Array<ChangingPair<Volume>>,
servicePairs: Array<ChangingPair<Service>>,
): CompositionStep | undefined {
): CompositionStep[] {
if (
needsDownload &&
this.dependenciesMetForServiceFetch(target, servicePairs)
) {
// We know the service name exists as it always does for targets
return generateStep('fetch', {
image: imageManager.imageFromService(target),
serviceName: target.serviceName,
});
return [
generateStep('fetch', {
image: imageManager.imageFromService(target),
serviceName: target.serviceName,
}),
];
} else if (
target != null &&
this.dependenciesMetForServiceStart(
target,
targetApp,
@ -723,7 +811,15 @@ export class App {
servicePairs,
)
) {
return generateStep('start', { target });
if (!servicesLocked) {
this.services
.concat(targetApp.services)
.forEach((svc) => appsToLock[target.appId].add(svc.serviceName));
return [];
}
return [generateStep('start', { target })];
} else {
return [];
}
}

View File

@ -2,6 +2,7 @@ import * as imageManager from './images';
import type Service from './service';
import type { CompositionStep } from './composition-steps';
import { generateStep } from './composition-steps';
import type { AppsToLockMap } from './app';
import { InternalInconsistencyError } from '../lib/errors';
import { checkString } from '../lib/validation';
@ -12,43 +13,82 @@ export interface StrategyContext {
dependenciesMetForStart: boolean;
dependenciesMetForKill: boolean;
needsSpecialKill: boolean;
services: Service[];
servicesLocked: boolean;
appsToLock: AppsToLockMap;
}
function generateLockThenKillStep(
current: Service,
currentServices: Service[],
servicesLocked: boolean,
appsToLock: AppsToLockMap,
): CompositionStep[] {
if (!servicesLocked) {
currentServices.forEach((svc) =>
appsToLock[svc.appId].add(svc.serviceName),
);
return [];
}
return [generateStep('kill', { current })];
}
export function getStepsFromStrategy(
strategy: string,
context: StrategyContext,
): CompositionStep {
): CompositionStep[] {
switch (strategy) {
case 'download-then-kill':
if (context.needsDownload && context.target) {
return generateStep('fetch', {
image: imageManager.imageFromService(context.target),
serviceName: context.target.serviceName,
});
return [
generateStep('fetch', {
image: imageManager.imageFromService(context.target),
serviceName: context.target.serviceName,
}),
];
} else if (context.dependenciesMetForKill) {
// We only kill when dependencies are already met, so that we minimize downtime
return generateStep('kill', { current: context.current });
return generateLockThenKillStep(
context.current,
context.services,
context.servicesLocked,
context.appsToLock,
);
} else {
return generateStep('noop', {});
return [generateStep('noop', {})];
}
case 'kill-then-download':
case 'delete-then-download':
return generateStep('kill', { current: context.current });
return generateLockThenKillStep(
context.current,
context.services,
context.servicesLocked,
context.appsToLock,
);
case 'hand-over':
if (context.needsDownload && context.target) {
return generateStep('fetch', {
image: imageManager.imageFromService(context.target),
serviceName: context.target.serviceName,
});
return [
generateStep('fetch', {
image: imageManager.imageFromService(context.target),
serviceName: context.target.serviceName,
}),
];
} else if (context.needsSpecialKill && context.dependenciesMetForKill) {
return generateStep('kill', { current: context.current });
return generateLockThenKillStep(
context.current,
context.services,
context.servicesLocked,
context.appsToLock,
);
} else if (context.dependenciesMetForStart && context.target) {
return generateStep('handover', {
current: context.current,
target: context.target,
});
return [
generateStep('handover', {
current: context.current,
target: context.target,
}),
];
} else {
return generateStep('noop', {});
return [generateStep('noop', {})];
}
default:
throw new InternalInconsistencyError(

View File

@ -112,6 +112,8 @@ describe('compose/application-manager', () => {
downloading,
availableImages,
containerIdsByAppId,
// Mock lock taken to avoid takeLock step
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
);
@ -222,6 +224,8 @@ describe('compose/application-manager', () => {
downloading,
availableImages,
containerIdsByAppId,
// Mock lock taken to avoid takeLock step
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
);
@ -272,6 +276,8 @@ describe('compose/application-manager', () => {
downloading,
availableImages,
containerIdsByAppId,
// Mock lock taken to avoid takeLock step
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
);
@ -403,6 +409,8 @@ describe('compose/application-manager', () => {
downloading: c1.downloading,
availableImages: c1.availableImages,
containerIdsByAppId: c1.containerIdsByAppId,
// Mock lock taken for `main` service which just needs metadata updated
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
);
// There should be two noop steps, one for target service which is still downloading,
@ -449,6 +457,10 @@ describe('compose/application-manager', () => {
downloading,
availableImages,
containerIdsByAppId,
// Mock locks taken for all services in either current or target state
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['old', 'main', 'new'] },
]),
},
);
// Service `old` is safe to kill after download for `new` has completed
@ -495,8 +507,10 @@ describe('compose/application-manager', () => {
availableImages: [],
containerIdsByAppId: c1.containerIdsByAppId,
// Mock locks for service to be updated via updateMetadata
// to avoid takeLock step
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
// or kill to avoid takeLock step
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['old', 'main', 'new'] },
]),
},
);
// Service `new` should be fetched
@ -569,6 +583,10 @@ describe('compose/application-manager', () => {
}),
],
containerIdsByAppId: c1.containerIdsByAppId,
// Mock lock taken for all services in target state
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['old', 'main', 'new'] },
]),
},
);
// Service `new` should be started
@ -610,8 +628,10 @@ describe('compose/application-manager', () => {
availableImages: [],
containerIdsByAppId: c1.containerIdsByAppId,
// Mock locks for service to be updated via updateMetadata
// to avoid takeLock step
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
// or kill to avoid takeLock step
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['old', 'main', 'new'] },
]),
},
);
// Service `new` should be fetched
@ -684,6 +704,10 @@ describe('compose/application-manager', () => {
}),
],
containerIdsByAppId: c1.containerIdsByAppId,
// Mock lock taken for all services in target state
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['main', 'new'] },
]),
},
);
// Service `new` should be started
@ -780,6 +804,10 @@ describe('compose/application-manager', () => {
}),
],
containerIdsByAppId,
// Mock locks taken for all services in target state
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['one', 'two'] },
]),
},
);
expectSteps('start', steps3, 2);
@ -848,6 +876,8 @@ describe('compose/application-manager', () => {
downloading,
availableImages,
containerIdsByAppId,
// Mock lock taken to avoid takeLock step
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
);
@ -928,6 +958,10 @@ describe('compose/application-manager', () => {
downloading,
availableImages,
containerIdsByAppId,
// Mock locks taken to avoid takeLock step
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['main', 'dep'] },
]),
},
);
@ -993,6 +1027,10 @@ describe('compose/application-manager', () => {
downloading,
availableImages,
containerIdsByAppId,
// Mock locks taken to avoid takeLock step
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['main', 'dep'] },
]),
},
);
@ -1061,6 +1099,10 @@ describe('compose/application-manager', () => {
downloading,
availableImages,
containerIdsByAppId,
// Mock locks taken to avoid takeLock step
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['main', 'dep'] },
]),
},
);
@ -1103,6 +1145,11 @@ describe('compose/application-manager', () => {
downloading,
availableImages,
containerIdsByAppId,
// Mock lock already taken for the new and leftover services
locksTaken: new LocksTakenMap([
{ appId: 5, services: ['old-service'] },
{ appId: 1, services: ['main'] },
]),
},
);
@ -1608,6 +1655,11 @@ describe('compose/application-manager', () => {
downloading,
availableImages,
containerIdsByAppId,
// Mock locks taken to avoid takeLock step
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['main'] },
{ appId: 2, services: ['main'] },
]),
},
);
@ -1630,6 +1682,699 @@ describe('compose/application-manager', () => {
).to.have.lengthOf(1);
});
describe('taking and releasing locks', () => {
it('should take locks for all services in current state when they should be killed', async () => {
const targetApps = createApps(
{
services: [],
networks: [DEFAULT_NETWORK],
},
true,
);
const { currentApps, availableImages, downloading, containerIdsByAppId } =
createCurrentState({
services: [
await createService({ serviceName: 'one' }),
await createService({ serviceName: 'two' }),
],
networks: [DEFAULT_NETWORK],
images: [],
});
// takeLock
const steps = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
downloading,
availableImages,
containerIdsByAppId,
},
);
const [takeLockStep] = expectSteps('takeLock', steps, 1, 1);
expect(takeLockStep)
.to.have.property('services')
.that.deep.includes.members(['one', 'two']);
// kill
const steps2 = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
downloading,
availableImages,
containerIdsByAppId,
// Mock locks taken
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['one', 'two'] },
]),
},
);
expectSteps('kill', steps2, 2);
});
it('should take locks for all services in current state when they should be stopped', async () => {
const targetApps = createApps(
{
services: [
await createService({
serviceName: 'one',
running: false,
}),
await createService({
serviceName: 'two',
running: false,
}),
],
networks: [DEFAULT_NETWORK],
},
true,
);
const { currentApps, availableImages, downloading, containerIdsByAppId } =
createCurrentState({
services: [
await createService({ serviceName: 'one' }),
await createService({ serviceName: 'two' }),
],
networks: [DEFAULT_NETWORK],
images: [],
});
// takeLock
const steps = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
downloading,
availableImages,
containerIdsByAppId,
},
);
const [takeLockStep] = expectSteps('takeLock', steps, 1, 1);
expect(takeLockStep)
.to.have.property('services')
.that.deep.includes.members(['one', 'two']);
// stop
const steps2 = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
downloading,
availableImages,
containerIdsByAppId,
// Mock locks taken
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['one', 'two'] },
]),
},
);
expectSteps('stop', steps2, 2);
});
it('should take locks for all services in target state before they should be started', async () => {
const targetApps = createApps(
{
services: [
await createService({ serviceName: 'one', image: 'one-image' }),
await createService({ serviceName: 'two', image: 'two-image' }),
],
networks: [DEFAULT_NETWORK],
},
true,
);
const { currentApps, availableImages, downloading, containerIdsByAppId } =
createCurrentState({
services: [],
networks: [DEFAULT_NETWORK],
images: [
createImage({
serviceName: 'one',
name: 'one-image',
}),
createImage({
serviceName: 'two',
name: 'two-image',
}),
],
});
// takeLock
const steps = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
downloading,
availableImages,
containerIdsByAppId,
},
);
const [takeLockStep] = expectSteps('takeLock', steps, 1, 1);
expect(takeLockStep)
.to.have.property('services')
.that.deep.includes.members(['one', 'two']);
// start
const steps2 = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
downloading,
availableImages,
containerIdsByAppId,
// Mock locks taken
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['one', 'two'] },
]),
},
);
expectSteps('start', steps2, 2);
});
it('should take locks for all services in current and target states when services should be stopped, started, or killed', async () => {
const targetApps = createApps(
{
services: [
await createService({
serviceName: 'to-stop',
image: 'image-to-stop',
running: false,
}),
await createService({
serviceName: 'to-start',
image: 'image-to-start',
}),
await createService({
serviceName: 'unchanged',
image: 'image-unchanged',
}),
],
networks: [DEFAULT_NETWORK],
},
true,
);
const { currentApps, availableImages, downloading, containerIdsByAppId } =
createCurrentState({
services: [
await createService({
serviceName: 'to-stop',
image: 'image-to-stop',
}),
await createService({
serviceName: 'to-kill',
image: 'image-to-kill',
}),
await createService({
serviceName: 'unchanged',
image: 'image-unchanged',
}),
],
networks: [DEFAULT_NETWORK],
images: [
createImage({ serviceName: 'to-start', name: 'image-to-start' }),
createImage({ serviceName: 'to-stop', name: 'image-to-stop' }),
createImage({ serviceName: 'unchanged', name: 'image-unchanged' }),
],
});
const steps = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
downloading,
availableImages,
containerIdsByAppId,
},
);
// No matter the number of services, we should see a single takeLock for all services
// regardless if they're only in current or only in target
const [takeLockStep] = expectSteps('takeLock', steps, 1, 1);
expect(takeLockStep)
.to.have.property('services')
.that.deep.includes.members([
'to-stop',
'to-kill',
'to-start',
'unchanged',
]);
});
it('should download images before taking locks & killing when update strategy is download-then-kill or handover', async () => {
const targetApps = createApps(
{
services: [
await createService({
serviceName: 'one',
image: 'one',
labels: {
'io.balena.update.strategy': 'download-then-kill',
'io.updated': 'true',
},
}),
await createService({
serviceName: 'two',
image: 'two',
labels: {
'io.balena.update.strategy': 'handover',
'io.updated': 'true',
},
}),
],
networks: [DEFAULT_NETWORK],
},
true,
);
const { currentApps, availableImages, downloading, containerIdsByAppId } =
createCurrentState({
services: [
await createService({
serviceName: 'one',
image: 'one',
labels: { 'io.balena.update.strategy': 'download-then-kill' },
}),
await createService({
serviceName: 'two',
image: 'two',
labels: { 'io.balena.update.strategy': 'handover' },
}),
],
networks: [DEFAULT_NETWORK],
});
// fetch
const steps = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
downloading: [],
availableImages: [],
containerIdsByAppId,
},
);
expectSteps('fetch', steps, 2);
// noop while downloading
const steps2 = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
downloading: ['one', 'two'],
availableImages: [],
containerIdsByAppId,
},
);
expectSteps('noop', steps2, 2);
// takeLock after download complete
const steps3 = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
downloading,
availableImages,
containerIdsByAppId,
},
);
const [takeLockStep] = expectSteps('takeLock', steps3, 1);
expect(takeLockStep)
.to.have.property('services')
.that.deep.includes.members(['one', 'two']);
// kill
const steps4 = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
downloading,
availableImages,
containerIdsByAppId,
// Mock locks taken
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['one', 'two'] },
]),
},
);
expectSteps('kill', steps4, 2);
});
it('should take locks & kill before downloading images when update strategy is kill|delete-then-download', async () => {
const targetApps = createApps(
{
services: [
await createService({
serviceName: 'one',
labels: {
'io.balena.update.strategy': 'kill-then-download',
'io.updated': 'true',
},
}),
await createService({
serviceName: 'two',
labels: {
'io.balena.update.strategy': 'delete-then-download',
'io.updated': 'true',
},
}),
],
networks: [DEFAULT_NETWORK],
},
true,
);
const { currentApps, availableImages, containerIdsByAppId } =
createCurrentState({
services: [
await createService({
serviceName: 'one',
labels: { 'io.balena.update.strategy': 'kill-then-download' },
}),
await createService({
serviceName: 'two',
labels: { 'io.balena.update.strategy': 'delete-then-download' },
}),
],
networks: [DEFAULT_NETWORK],
});
// takeLock
const steps = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
// Images haven't finished downloading,
// but kill steps should still be inferred
downloading: ['one', 'two'],
availableImages,
containerIdsByAppId,
},
);
const [takeLockStep] = expectSteps('takeLock', steps, 1, 1);
expect(takeLockStep)
.to.have.property('services')
.that.deep.includes.members(['one', 'two']);
// kill
const steps2 = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
downloading: ['one', 'two'],
availableImages,
containerIdsByAppId,
// Mock locks taken
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['one', 'two'] },
]),
},
);
expectSteps('kill', steps2, 2);
});
it('should infer takeLock & kill steps for dependent services before their network should be removed', async () => {
const targetApps = createApps(
{
services: [
await createService({ serviceName: 'main', appUuid: 'deadbeef' }),
],
networks: [DEFAULT_NETWORK],
},
true,
);
const { currentApps, availableImages, downloading, containerIdsByAppId } =
createCurrentState({
services: [
await createService({
serviceName: 'main',
appUuid: 'deadbeef',
composition: { networks: { test: {} } },
}),
],
networks: [
DEFAULT_NETWORK,
Network.fromComposeObject('test', 1, 'deadbeef', {
driver: 'bridge',
}),
],
});
// takeLock
const steps = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
downloading,
availableImages,
containerIdsByAppId,
},
);
const [takeLockStep] = expectSteps('takeLock', steps, 1, 1);
expect(takeLockStep)
.to.have.property('services')
.that.deep.includes.members(['main']);
// kill
const steps2 = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
downloading,
availableImages,
containerIdsByAppId,
// Mock locks taken
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
);
expectSteps('kill', steps2);
// removeNetwork
const intermediateCurrent = createCurrentState({
services: [],
networks: [
DEFAULT_NETWORK,
Network.fromComposeObject('test', 1, 'deadbeef', {
driver: 'bridge',
}),
],
});
const steps3 = await applicationManager.inferNextSteps(
intermediateCurrent.currentApps,
targetApps,
{
downloading: intermediateCurrent.downloading,
availableImages: intermediateCurrent.availableImages,
containerIdsByAppId: intermediateCurrent.containerIdsByAppId,
// Mock locks taken
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
);
expectSteps('removeNetwork', steps3);
});
it('should infer takeLock & kill steps for dependent services before their network should have config changed', async () => {
const targetApps = createApps(
{
services: [
await createService({
serviceName: 'main',
appUuid: 'deadbeef',
composition: { networks: { test: {} } },
}),
],
networks: [
DEFAULT_NETWORK,
Network.fromComposeObject('test', 1, 'deadbeef', {
driver: 'local',
}),
],
},
true,
);
const { currentApps, availableImages, downloading, containerIdsByAppId } =
createCurrentState({
services: [
await createService({
serviceName: 'main',
appUuid: 'deadbeef',
composition: { networks: { test: {} } },
}),
],
networks: [
DEFAULT_NETWORK,
Network.fromComposeObject('test', 1, 'deadbeef', {
driver: 'bridge',
}),
],
});
// takeLock
const steps = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
downloading,
availableImages,
containerIdsByAppId,
},
);
const [takeLockStep] = expectSteps('takeLock', steps, 1, 1);
expect(takeLockStep)
.to.have.property('services')
.that.deep.includes.members(['main']);
// kill
const steps2 = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
downloading,
availableImages,
containerIdsByAppId,
// Mock locks taken
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
);
expectSteps('kill', steps2, 1);
// removeNetwork
const intermediateCurrent = createCurrentState({
services: [],
networks: [
DEFAULT_NETWORK,
Network.fromComposeObject('test', 1, 'deadbeef', {
driver: 'bridge',
}),
],
});
const steps3 = await applicationManager.inferNextSteps(
intermediateCurrent.currentApps,
targetApps,
{
downloading: intermediateCurrent.downloading,
availableImages: intermediateCurrent.availableImages,
containerIdsByAppId: intermediateCurrent.containerIdsByAppId,
// Mock locks taken
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
);
expectSteps('removeNetwork', steps3);
});
it('should infer takeLock & kill steps for dependent services before their volume should have config changed', async () => {
const targetApps = createApps(
{
services: [
await createService({
serviceName: 'main',
appUuid: 'deadbeef',
composition: { volumes: ['test:/test'] },
}),
],
networks: [DEFAULT_NETWORK],
volumes: [
Volume.fromComposeObject('test', 1, 'deadbeef', {
labels: { 'io.updated': 'true' },
}),
],
},
true,
);
const { currentApps, availableImages, downloading, containerIdsByAppId } =
createCurrentState({
services: [
await createService({
serviceName: 'main',
appUuid: 'deadbeef',
composition: { volumes: ['test:/test'] },
}),
],
networks: [DEFAULT_NETWORK],
volumes: [Volume.fromComposeObject('test', 1, 'deadbeef')],
});
// takeLock
const steps = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
downloading,
availableImages,
containerIdsByAppId,
},
);
const [takeLockStep] = expectSteps('takeLock', steps, 1, 1);
expect(takeLockStep)
.to.have.property('services')
.that.deep.includes.members(['main']);
// kill
const steps2 = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
downloading,
availableImages,
containerIdsByAppId,
// Mock locks taken
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
);
expectSteps('kill', steps2, 1);
// removeVolume
const intermediateCurrent = createCurrentState({
services: [],
networks: [DEFAULT_NETWORK],
volumes: [Volume.fromComposeObject('test', 1, 'deadbeef')],
});
const steps3 = await applicationManager.inferNextSteps(
intermediateCurrent.currentApps,
targetApps,
{
downloading: intermediateCurrent.downloading,
availableImages: intermediateCurrent.availableImages,
containerIdsByAppId: intermediateCurrent.containerIdsByAppId,
// Mock locks taken
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
);
expectSteps('removeVolume', steps3);
});
it('should release locks before settling state', async () => {
const targetApps = createApps(
{
services: [
await createService({ serviceName: 'one' }),
await createService({ serviceName: 'two' }),
],
networks: [DEFAULT_NETWORK],
},
true,
);
const { currentApps, availableImages, downloading, containerIdsByAppId } =
createCurrentState({
services: [
await createService({ serviceName: 'one' }),
await createService({ serviceName: 'two' }),
],
networks: [DEFAULT_NETWORK],
});
const steps = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
downloading,
availableImages,
containerIdsByAppId,
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['one', 'two'] },
]),
},
);
const [releaseLockStep] = expectSteps('releaseLock', steps, 1, 1);
expect(releaseLockStep).to.have.property('appId').that.equals(1);
});
});
describe("getting application's current state", () => {
let getImagesState: sinon.SinonStub;
let getServicesState: sinon.SinonStub;
@ -2032,6 +2777,10 @@ describe('compose/application-manager', () => {
downloading,
availableImages,
containerIdsByAppId,
// Mock locks taken for all services in target state
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['one', 'two', 'three', 'four'] },
]),
});
[startStep1, startStep2, startStep3, startStep4].forEach((step) => {

View File

@ -170,13 +170,38 @@ describe('compose/app', () => {
isTarget: true,
});
// Calculate steps
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const availableImages = [createImage({ serviceName: 'test' })];
// Take lock first
const steps = current.nextStepsForAppUpdate(
{
...defaultContext,
availableImages,
},
target,
);
const [lockStep] = expectSteps('takeLock', steps);
expect(lockStep)
.to.have.property('services')
.that.deep.includes.members(['test']);
const [killStep] = expectSteps('kill', steps);
// Then kill
const steps2 = current.nextStepsForAppUpdate(
{
...defaultContext,
availableImages,
// Mock lock already taken
locksTaken: new LocksTakenMap([{ appId: 1, services: ['test'] }]),
},
target,
);
const [killStep] = expectSteps('kill', steps2);
expect(killStep)
.to.have.property('current')
.that.deep.includes({ serviceName: 'test' });
// No remove volume steps until dependencies are killed
expectNoStep('removeVolume', steps);
expectNoStep('removeVolume', steps2);
});
it('should correctly infer to remove an app volumes when the app is being removed', () => {
@ -250,17 +275,32 @@ describe('compose/app', () => {
volumes: [volume],
});
// Step 1: kill
const steps = current.nextStepsForAppUpdate(
// Step 1: takeLock
const lockStep = current.nextStepsForAppUpdate(
contextWithImages,
intermediateTarget,
);
expectSteps('kill', steps);
expectSteps('takeLock', lockStep, 1, 1);
// Step 2: noop (service is stopping)
// Step 2: kill
const killSteps = current.nextStepsForAppUpdate(
{
...contextWithImages,
// Mock locks already taken
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
intermediateTarget,
);
expectSteps('kill', killSteps);
// Step 3: noop (service is stopping)
service.status = 'Stopping';
const secondStageSteps = current.nextStepsForAppUpdate(
contextWithImages,
{
...contextWithImages,
// Mock locks already taken
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
intermediateTarget,
);
expectSteps('noop', secondStageSteps);
@ -286,7 +326,7 @@ describe('compose/app', () => {
volumes: [],
});
// Step 3: createVolume
// Step 4: createVolume
service.status = 'Running';
const target = createApp({
services: [service],
@ -303,18 +343,29 @@ describe('compose/app', () => {
expect(recreateVolumeSteps).to.have.length(1);
expectSteps('createVolume', recreateVolumeSteps);
// Final step: start service
// Step 5: takeLock
const currentWithVolumeRecreated = createApp({
services: [],
networks: [DEFAULT_NETWORK],
volumes: [volume],
});
const createServiceSteps =
const lockStepAfterRecreate =
currentWithVolumeRecreated.nextStepsForAppUpdate(
contextWithImages,
target,
);
expectSteps('takeLock', lockStepAfterRecreate, 1, 1);
// Final step: start service
const createServiceSteps =
currentWithVolumeRecreated.nextStepsForAppUpdate(
{
...contextWithImages,
// Mock locks already taken
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
target,
);
expectSteps('start', createServiceSteps);
});
});
@ -441,6 +492,7 @@ describe('compose/app', () => {
});
const target = createApp({
networks: [
DEFAULT_NETWORK,
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
],
services: [
@ -454,14 +506,61 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [killStep] = expectSteps('kill', steps);
console.log(killStep);
const availableImages = [createImage({ appUuid: 'deadbeef' })];
// Take lock first
const steps = current.nextStepsForAppUpdate(
{
...defaultContext,
availableImages,
},
target,
);
const [lockStep] = expectSteps('takeLock', steps);
expect(lockStep)
.to.have.property('services')
.that.deep.includes.members(['test']);
// Then kill
const steps2 = current.nextStepsForAppUpdate(
{
...defaultContext,
availableImages,
locksTaken: new LocksTakenMap([{ appId: 1, services: ['test'] }]),
},
target,
);
const [killStep] = expectSteps('kill', steps2);
expect(killStep).to.have.property('current').that.deep.includes({
serviceName: 'test',
});
// removeNetwork should not be generated until after the kill
expectNoStep('removeNetwork', steps);
expectNoStep('removeNetwork', steps2);
// Then remove duplicate networks
const current2 = createApp({
appUuid: 'deadbeef',
networks: [
DEFAULT_NETWORK,
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
],
services: [],
});
const steps3 = current2.nextStepsForAppUpdate(
{
...defaultContext,
availableImages,
locksTaken: new LocksTakenMap([{ appId: 1, services: ['test'] }]),
},
target,
);
const [removeNetworkStep] = expectSteps('removeNetwork', steps3);
expect(removeNetworkStep).to.have.property('current').that.deep.includes({
name: 'test-network',
});
});
it('should correctly infer more than one network removal step', () => {
@ -564,15 +663,33 @@ describe('compose/app', () => {
});
const availableImages = [createImage({ appUuid: 'deadbeef' })];
// Take lock first
const steps = current.nextStepsForAppUpdate(
{ ...defaultContext, availableImages },
target,
);
const [killStep] = expectSteps('kill', steps);
const [lockStep] = expectSteps('takeLock', steps);
expect(lockStep)
.to.have.property('services')
.that.deep.includes.members(['test']);
// Then kill
const steps2 = current.nextStepsForAppUpdate(
{
...defaultContext,
availableImages,
locksTaken: new LocksTakenMap([{ appId: 1, services: ['test'] }]),
},
target,
);
const [killStep] = expectSteps('kill', steps2);
expect(killStep)
.to.have.property('current')
.that.deep.includes({ serviceName: 'test' });
// Network should not be removed until after dependency kills
expectNoStep('removeNetwork', steps);
expectNoStep('removeNetwork', steps2);
});
it('should kill dependencies of networks before changing config', async () => {
@ -599,16 +716,37 @@ describe('compose/app', () => {
],
isTarget: true,
});
const availableImages = [createImage({ appId: 1, serviceName: 'test' })];
// Take lock first
const steps = current.nextStepsForAppUpdate(
{
...defaultContext,
availableImages,
},
target,
);
const [lockStep] = expectSteps('takeLock', steps);
expect(lockStep)
.to.have.property('services')
.that.deep.includes.members(['test']);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [killStep] = expectSteps('kill', steps);
// Then kill
const steps2 = current.nextStepsForAppUpdate(
{
...defaultContext,
availableImages,
locksTaken: new LocksTakenMap([{ appId: 1, services: ['test'] }]),
},
target,
);
const [killStep] = expectSteps('kill', steps2);
expect(killStep)
.to.have.property('current')
.that.deep.includes({ serviceName: 'test' });
// We shouldn't try to remove the network until we have gotten rid of the dependencies
// Network should not be removed until after dependency kills
expectNoStep('removeNetwork', steps);
expectNoStep('removeNetwork', steps2);
});
it('should always kill dependencies of networks before removing', async () => {
@ -655,19 +793,35 @@ describe('compose/app', () => {
createImage({ appId: 1, serviceName: 'one', name: 'alpine' }),
createImage({ appId: 1, serviceName: 'two', name: 'alpine' }),
];
// Take lock first
const steps = current.nextStepsForAppUpdate(
{ ...defaultContext, availableImages },
target,
);
const [killStep] = expectSteps('kill', steps);
const [lockStep] = expectSteps('takeLock', steps);
expect(lockStep)
.to.have.property('services')
.that.deep.includes.members(['one']);
// Then kill
const steps2 = current.nextStepsForAppUpdate(
{
...defaultContext,
availableImages,
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['one', 'two'] },
]),
},
target,
);
const [killStep] = expectSteps('kill', steps2);
expect(killStep)
.to.have.property('current')
.that.deep.includes({ serviceName: 'one' });
// We shouldn't try to remove the network until we have gotten rid of the dependencies
expectNoStep('removeNetwork', steps);
expectNoStep('removeNetwork', steps2);
});
it('should kill dependencies of networks before updating between releases', async () => {
@ -718,20 +872,35 @@ describe('compose/app', () => {
createImage({ appId: 1, serviceName: 'one', name: 'alpine' }),
createImage({ appId: 1, serviceName: 'two', name: 'alpine' }),
];
// Take lock first
const steps = current.nextStepsForAppUpdate(
{ ...defaultContext, availableImages },
target,
);
expectSteps('kill', steps, 2);
const [lockStep] = expectSteps('takeLock', steps);
expect(lockStep)
.to.have.property('services')
.that.deep.includes.members(['one', 'two']);
expect(steps.map((s) => (s as any).current.serviceName)).to.have.members([
'one',
'two',
]);
// Then kill
const steps2 = current.nextStepsForAppUpdate(
{
...defaultContext,
availableImages,
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['one', 'two'] },
]),
},
target,
);
expectSteps('kill', steps2, 2);
expect(steps2.map((s) => (s as any).current.serviceName)).to.have.members(
['one', 'two'],
);
// We shouldn't try to remove the network until we have gotten rid of the dependencies
expectNoStep('removeNetwork', steps);
expectNoStep('removeNetwork', steps2);
});
it('should create the default network if it does not exist', () => {
@ -847,6 +1016,7 @@ describe('compose/app', () => {
isTarget: true,
});
// Take lock first
const steps = current.nextStepsForAppUpdate(
{
...defaultContext,
@ -856,7 +1026,24 @@ describe('compose/app', () => {
},
target,
);
const [killStep] = expectSteps('kill', steps);
const [lockStep] = expectSteps('takeLock', steps);
expect(lockStep)
.to.have.property('services')
.that.deep.includes.members(['main', 'aux']);
// Then kill
const steps2 = current.nextStepsForAppUpdate(
{
...defaultContext,
availableImages: [createImage({ serviceName: 'main' })],
// Mock locks already taken
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['main', 'aux'] },
]),
},
target,
);
const [killStep] = expectSteps('kill', steps2);
expect(killStep)
.to.have.property('current')
.to.deep.include({ serviceName: 'aux' });
@ -1013,8 +1200,22 @@ describe('compose/app', () => {
isTarget: true,
});
// Take lock first
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [stopStep] = expectSteps('stop', steps);
const [lockStep] = expectSteps('takeLock', steps);
expect(lockStep)
.to.have.property('services')
.that.deep.includes.members(['main']);
// Then stop
const steps2 = current.nextStepsForAppUpdate(
{
...defaultContext,
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
target,
);
const [stopStep] = expectSteps('stop', steps2);
expect(stopStep)
.to.have.property('current')
.to.deep.include({ serviceName: 'main' });
@ -1073,9 +1274,19 @@ describe('compose/app', () => {
isTarget: true,
});
// should see a 'stop'
// Take lock first
const steps = current.nextStepsForAppUpdate(contextWithImages, target);
const [lockStep] = expectSteps('takeLock', steps);
expect(lockStep)
.to.have.property('services')
.that.deep.includes.members(['main']);
// Then kill
const stepsToIntermediate = current.nextStepsForAppUpdate(
contextWithImages,
{
...contextWithImages,
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
target,
);
const [killStep] = expectSteps('kill', stepsToIntermediate);
@ -1090,9 +1301,12 @@ describe('compose/app', () => {
networks: [DEFAULT_NETWORK],
});
// now should see a 'start'
// Then start
const stepsToTarget = intermediate.nextStepsForAppUpdate(
contextWithImages,
{
...contextWithImages,
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
target,
);
const [startStep] = expectSteps('start', stepsToTarget);
@ -1106,12 +1320,6 @@ describe('compose/app', () => {
});
it('should not start a container when it depends on a service which is being installed', async () => {
const availableImages = [
createImage({ appId: 1, serviceName: 'main', name: 'main-image' }),
createImage({ appId: 1, serviceName: 'dep', name: 'dep-image' }),
];
const contextWithImages = { ...defaultContext, ...{ availableImages } };
const current = createApp({
services: [
await createService(
@ -1148,12 +1356,24 @@ describe('compose/app', () => {
isTarget: true,
});
const availableImages = [
createImage({ appId: 1, serviceName: 'main', name: 'main-image' }),
createImage({ appId: 1, serviceName: 'dep', name: 'dep-image' }),
];
// As service is already being installed, lock for target should have been taken
const contextWithImages = {
...defaultContext,
...{ availableImages },
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['main', 'dep'] },
]),
};
// Only one start step and it should be that of the 'dep' service
const stepsToIntermediate = current.nextStepsForAppUpdate(
contextWithImages,
target,
);
// Only one start step and it should be that of the 'dep' service
const [startStep] = expectSteps('start', stepsToIntermediate);
expect(startStep)
.to.have.property('target')
@ -1246,11 +1466,28 @@ describe('compose/app', () => {
isTarget: true,
});
const stepsToIntermediate = current.nextStepsForAppUpdate(
// Take lock first
const stepsToIntermediateBeforeLock = current.nextStepsForAppUpdate(
contextWithImages,
target,
);
const [takeLockStep] = expectSteps(
'takeLock',
stepsToIntermediateBeforeLock,
);
expect(takeLockStep)
.to.have.property('services')
.that.deep.includes.members(['main']);
// Then kill
const stepsToIntermediate = current.nextStepsForAppUpdate(
{
...contextWithImages,
// Mock locks taken before kill
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
target,
);
const [killStep] = expectSteps('kill', stepsToIntermediate);
expect(killStep)
.to.have.property('current')
@ -1263,7 +1500,12 @@ describe('compose/app', () => {
});
const stepsToTarget = intermediate.nextStepsForAppUpdate(
contextWithImages,
{
...contextWithImages,
// Mock locks still taken after kill (releaseLock not
// yet inferred as state is not yet settled)
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
target,
);
@ -1375,23 +1617,42 @@ describe('compose/app', () => {
isTarget: true,
});
const stepsFirstTry = current.nextStepsForAppUpdate(
// Take lock first
const stepsBeforeLock = current.nextStepsForAppUpdate(
contextWithImages,
target,
);
const [takeLockStep] = expectSteps('takeLock', stepsBeforeLock);
expect(takeLockStep)
.to.have.property('services')
.that.deep.includes.members(['main']);
// Then kill
const stepsFirstTry = current.nextStepsForAppUpdate(
{
...contextWithImages,
// Mock locks taken from previous step
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
target,
);
const [killStep] = expectSteps('kill', stepsFirstTry);
expect(killStep)
.to.have.property('current')
.that.deep.includes({ serviceName: 'main' });
// if at first you don't succeed
// As long as a kill step has not succeeded (current state hasn't
// changed), a kill step should be generated.
const stepsSecondTry = current.nextStepsForAppUpdate(
contextWithImages,
{
...contextWithImages,
// Mock locks taken from previous step
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
target,
);
// Since current state has not changed, another kill step needs to be generated
const [newKillStep] = expectSteps('kill', stepsSecondTry);
expect(newKillStep)
.to.have.property('current')
@ -1419,8 +1680,22 @@ describe('compose/app', () => {
isTarget: true,
});
// Take lock first
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [killStep] = expectSteps('kill', steps);
const [lockStep] = expectSteps('takeLock', steps);
expect(lockStep)
.to.have.property('services')
.that.deep.includes.members(['test']);
// Then kill
const steps2 = current.nextStepsForAppUpdate(
{
...defaultContext,
locksTaken: new LocksTakenMap([{ appId: 1, services: ['test'] }]),
},
target,
);
const [killStep] = expectSteps('kill', steps2);
expect(killStep)
.to.have.property('current')
.that.deep.includes({ serviceName: 'test' });
@ -1485,16 +1760,31 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(
const contextWithImages = {
...defaultContext,
// With default download-then-kill strategy, target images
// should all be available before a kill step is inferred
availableImages: [createImage({ serviceName: 'three' })],
};
// Take lock first
const steps = current.nextStepsForAppUpdate(contextWithImages, target);
const [lockStep] = expectSteps('takeLock', steps);
expect(lockStep)
.to.have.property('services')
.that.deep.includes.members(['one', 'two', 'three']);
// Then kill
const steps2 = current.nextStepsForAppUpdate(
{
...defaultContext,
// With default download-then-kill strategy, target images
// should all be available before a kill step is inferred
availableImages: [createImage({ serviceName: 'three' })],
...contextWithImages,
// Mock locks already taken
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['one', 'two', 'three'] },
]),
},
target,
);
expectSteps('kill', steps, 2);
expectSteps('kill', steps2, 2);
});
it('should not infer a kill step with the default strategy before all target images have been downloaded', async () => {
@ -1599,21 +1889,6 @@ describe('compose/app', () => {
});
it('should infer a start step only when target images have been downloaded', async () => {
const contextWithImages = {
...defaultContext,
...{
downloading: [], // One of the images is being downloaded
availableImages: [
createImage({ appId: 1, name: 'main-image', serviceName: 'main' }),
createImage({
appId: 1,
name: 'other-image',
serviceName: 'other',
}),
],
},
};
const current = createApp({
services: [],
networks: [DEFAULT_NETWORK],
@ -1637,9 +1912,59 @@ describe('compose/app', () => {
isTarget: true,
});
// No kill steps should be generated
const steps = current.nextStepsForAppUpdate(contextWithImages, target);
expectSteps('start', steps, 2);
// No start steps should be generated as long as any target image is downloading
const steps = current.nextStepsForAppUpdate(
{
...defaultContext,
downloading: ['other-image'],
availableImages: [
createImage({ appId: 1, name: 'main-image', serviceName: 'main' }),
],
},
target,
);
expectNoStep('start', steps);
expectSteps('noop', steps, 1);
// Take lock before starting once downloads complete
const steps2 = current.nextStepsForAppUpdate(
{
...defaultContext,
availableImages: [
createImage({ appId: 1, name: 'main-image', serviceName: 'main' }),
createImage({
appId: 1,
name: 'other-image',
serviceName: 'other',
}),
],
},
target,
);
const [lockStep] = expectSteps('takeLock', steps2);
expect(lockStep)
.to.have.property('services')
.that.deep.includes.members(['main', 'other']);
// Then start
const steps3 = current.nextStepsForAppUpdate(
{
...defaultContext,
availableImages: [
createImage({ appId: 1, name: 'main-image', serviceName: 'main' }),
createImage({
appId: 1,
name: 'other-image',
serviceName: 'other',
}),
],
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['main', 'other'] },
]),
},
target,
);
expectSteps('start', steps3, 2);
});
});