balena-supervisor/test/unit/compose/app.spec.ts
Christina Ying Wang 10f294cf8e 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>
2024-04-04 14:07:47 -07:00

2117 lines
57 KiB
TypeScript

import { expect } from 'chai';
import type { Image } from '~/src/compose/images';
import Network from '~/src/compose/network';
import Volume from '~/src/compose/volume';
import { LocksTakenMap } from '~/lib/update-lock';
import {
createService,
createImage,
createApp,
DEFAULT_NETWORK,
expectSteps,
expectNoStep,
} from '~/test-lib/state-helper';
const defaultContext = {
keepVolumes: false,
force: false,
availableImages: [] as Image[],
containerIds: {},
downloading: [] as string[],
locksTaken: new LocksTakenMap(),
};
describe('compose/app', () => {
describe('volume state behavior', () => {
it('should correctly infer a volume create step', () => {
// Setup current and target apps
const current = createApp();
const target = createApp({
volumes: [Volume.fromComposeObject('test-volume', 1, 'deadbeef')],
isTarget: true,
});
// Calculate the steps
const steps = current.nextStepsForAppUpdate(defaultContext, target);
// Check that a createVolume step has been created
const [createVolumeStep] = expectSteps('createVolume', steps);
expect(createVolumeStep)
.to.have.property('target')
.that.deep.includes({ name: 'test-volume' });
});
it('should correctly infer more than one volume create step', () => {
const current = createApp();
const target = createApp({
volumes: [
Volume.fromComposeObject('test-volume', 1, 'deadbeef'),
Volume.fromComposeObject('test-volume-2', 1, 'deadbeef'),
],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
// Check that 2 createVolume steps are found
const createVolumeSteps = expectSteps('createVolume', steps, 2);
// Check that the steps contain the volumes without any order
// expectation
expect(
createVolumeSteps.filter(
(step: any) => step.target && step.target.name === 'test-volume',
),
).to.have.lengthOf(1);
expect(
createVolumeSteps.filter(
(step: any) => step.target && step.target.name === 'test-volume-2',
),
).to.have.lengthOf(1);
});
// We don't remove volumes until the end
it('should not infer a volume remove step when the app is still referenced', () => {
const current = createApp({
volumes: [
Volume.fromComposeObject('test-volume', 1, 'deadbeef'),
Volume.fromComposeObject('test-volume-2', 1, 'deadbeef'),
],
});
const target = createApp({
volumes: [Volume.fromComposeObject('test-volume-2', 1, 'deadbeef')],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
expectNoStep('removeVolume', steps);
});
it('should correctly infer volume recreation steps', () => {
const current = createApp({
volumes: [Volume.fromComposeObject('test-volume', 1, 'deadbeef')],
});
const target = createApp({
volumes: [
Volume.fromComposeObject('test-volume', 1, 'deadbeef', {
labels: { test: 'test' },
}),
],
isTarget: true,
});
// First step should create a volume removal step
const stepsForRemoval = current.nextStepsForAppUpdate(
defaultContext,
target,
);
const [removalStep] = expectSteps('removeVolume', stepsForRemoval);
expect(removalStep)
.to.have.property('current')
.that.has.property('name')
.that.equals('test-volume');
expect(removalStep)
.to.have.property('current')
.that.has.property('appId')
.that.equals(1);
// we are assuming that after the execution steps the current state of the
// app will look like this
const intermediate = createApp({
volumes: [],
});
// This test is extra since we have already tested that the volume gets created
const stepsForCreation = intermediate.nextStepsForAppUpdate(
defaultContext,
target,
);
const [creationStep] = expectSteps('createVolume', stepsForCreation);
expect(creationStep)
.to.have.property('target')
.that.has.property('config')
.that.deep.includes({
labels: {
'io.balena.supervised': 'true',
'io.balena.app-uuid': 'deadbeef',
test: 'test',
},
});
});
it('should kill dependencies of a volume before changing config', async () => {
const current = createApp({
services: [
await createService({
serviceName: 'test',
composition: { volumes: ['test-volume:/data'] },
}),
],
volumes: [Volume.fromComposeObject('test-volume', 1, 'deadbeef')],
networks: [DEFAULT_NETWORK],
});
const target = createApp({
services: [
await createService({
serviceName: 'test',
composition: { volumes: ['test-volume:/data'] },
}),
],
volumes: [
Volume.fromComposeObject('test-volume', 1, 'deadbeef', {
labels: { test: 'test' },
}),
],
networks: [DEFAULT_NETWORK],
isTarget: true,
});
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']);
// 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', () => {
const current = createApp({
volumes: [Volume.fromComposeObject('test-volume', 1, 'deadbeef')],
});
const steps = current.stepsToRemoveApp(defaultContext);
const [removeVolumeStep] = expectSteps('removeVolume', steps);
expect(removeVolumeStep).to.have.property('current').that.deep.includes({
name: 'test-volume',
});
});
it('should not output a kill step for a service which is already stopping when changing a volume', async () => {
const service = await createService({
serviceName: 'test',
composition: { volumes: ['test-volume:/data'] },
});
service.status = 'Stopping';
const current = createApp({
services: [service],
volumes: [Volume.fromComposeObject('test-volume', 1, 'deadbeef')],
});
const target = createApp({
services: [service],
volumes: [
Volume.fromComposeObject('test-volume', 1, 'deadbeef', {
labels: { test: 'test' },
}),
],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
expectNoStep('kill', steps);
});
it('should generate the correct step sequence for a volume purge request', async () => {
const service = await createService({
appId: 1,
appUuid: 'deadbeef',
image: 'test-image',
composition: { volumes: ['db-volume:/data'] },
});
const volume = Volume.fromComposeObject('db-volume', 1, 'deadbeef');
const contextWithImages = {
...defaultContext,
...{
availableImages: [
createImage({
appId: service.appId,
name: 'test-image',
}),
],
},
};
// Temporarily set target services & volumes to empty, as in doPurge
const intermediateTarget = createApp({
services: [],
networks: [DEFAULT_NETWORK],
isTarget: true,
});
// Generate initial state with one service & one volume
const current = createApp({
services: [service],
networks: [DEFAULT_NETWORK],
volumes: [volume],
});
// Step 1: takeLock
const lockStep = current.nextStepsForAppUpdate(
contextWithImages,
intermediateTarget,
);
expectSteps('takeLock', lockStep, 1, 1);
// 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,
// Mock locks already taken
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
intermediateTarget,
);
expectSteps('noop', secondStageSteps);
expect(secondStageSteps).to.have.length(1);
// No steps, simulate container removal & explicit volume removal as in doPurge
const currentWithServiceRemoved = createApp({
services: [],
networks: [DEFAULT_NETWORK],
volumes: [volume],
});
expect(
currentWithServiceRemoved.nextStepsForAppUpdate(
contextWithImages,
intermediateTarget,
),
).to.have.length(0);
// Simulate volume removal
const currentWithVolumesRemoved = createApp({
services: [],
networks: [DEFAULT_NETWORK],
volumes: [],
});
// Step 4: createVolume
service.status = 'Running';
const target = createApp({
services: [service],
networks: [DEFAULT_NETWORK],
volumes: [volume],
isTarget: true,
});
const recreateVolumeSteps =
currentWithVolumesRemoved.nextStepsForAppUpdate(
contextWithImages,
target,
);
expect(recreateVolumeSteps).to.have.length(1);
expectSteps('createVolume', recreateVolumeSteps);
// Step 5: takeLock
const currentWithVolumeRecreated = createApp({
services: [],
networks: [DEFAULT_NETWORK],
volumes: [volume],
});
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);
});
});
describe('network state behavior', () => {
it('should correctly infer a network create step', () => {
const current = createApp({ networks: [] });
const target = createApp({
networks: [Network.fromComposeObject('default', 1, 'deadbeef', {})],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [createNetworkStep] = expectSteps('createNetwork', steps);
expect(createNetworkStep).to.have.property('target').that.deep.includes({
name: 'default',
});
});
it('should correctly infer a network remove step', () => {
const current = createApp({
networks: [
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
],
});
const target = createApp({ networks: [], isTarget: true });
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [removeNetworkStep] = expectSteps('removeNetwork', steps);
expect(removeNetworkStep).to.have.property('current').that.deep.includes({
name: 'test-network',
});
});
it('should correctly remove default duplicate networks', () => {
const current = createApp({
networks: [DEFAULT_NETWORK, DEFAULT_NETWORK],
});
const target = createApp({
networks: [],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [removeNetworkStep] = expectSteps('removeNetwork', steps);
expect(removeNetworkStep).to.have.property('current').that.deep.includes({
name: 'default',
});
});
it('should correctly remove duplicate networks', () => {
const current = createApp({
networks: [
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
],
});
const target = createApp({
networks: [
// The target is a single network
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [removeNetworkStep] = expectSteps('removeNetwork', steps);
expect(removeNetworkStep).to.have.property('current').that.deep.includes({
name: 'test-network',
});
});
it('should ignore the duplicates if there are changes already', () => {
const current = createApp({
networks: [
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
],
});
const target = createApp({
networks: [
// The target is a single network
Network.fromComposeObject('test-network', 1, 'deadbeef', {
config_only: true,
}),
],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [removeNetworkStep] = expectSteps('removeNetwork', steps);
expect(removeNetworkStep).to.have.property('current').that.deep.includes({
name: 'test-network',
});
});
// This should never happen because there can never be a service that is refencing
// a network that has a duplicate
it('should generate service kill steps if there are duplicate networks', async () => {
const current = createApp({
appUuid: 'deadbeef',
networks: [
DEFAULT_NETWORK,
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
],
services: [
await createService({
appId: 1,
appUuid: 'deadbeef',
serviceName: 'test',
composition: { networks: ['test-network'] },
}),
],
});
const target = createApp({
networks: [
DEFAULT_NETWORK,
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
],
services: [
await createService({
appId: 1,
appUuid: 'deadbeef',
serviceName: 'test',
composition: { networks: ['test-network'] },
}),
],
isTarget: true,
});
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', () => {
const current = createApp({
networks: [
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
Network.fromComposeObject('test-network-2', 1, 'deadbeef', {}),
],
isTarget: true,
});
const target = createApp({ networks: [], isTarget: true });
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [first, second] = expectSteps('removeNetwork', steps, 2);
expect(first).to.have.property('current').that.deep.includes({
name: 'test-network',
});
expect(second).to.have.property('current').that.deep.includes({
name: 'test-network-2',
});
});
it('should correctly infer a network recreation step', () => {
const current = createApp({
networks: [
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
],
});
const target = createApp({
networks: [
Network.fromComposeObject('test-network', 1, 'deadbeef', {
labels: { TEST: 'TEST' },
}),
],
isTarget: true,
});
const stepsForRemoval = current.nextStepsForAppUpdate(
defaultContext,
target,
);
const [removeStep] = expectSteps('removeNetwork', stepsForRemoval);
expect(removeStep)
.to.have.property('current')
.that.deep.includes({ name: 'test-network' });
// We assume that the intermediate state looks like this
const intermediate = createApp({
networks: [],
});
const stepsForCreation = intermediate.nextStepsForAppUpdate(
defaultContext,
target,
);
const [createNetworkStep] = expectSteps(
'createNetwork',
stepsForCreation,
1,
2, // The update will also generate a step for the default network but we don't care about that
);
expect(createNetworkStep)
.to.have.property('target')
.that.deep.includes({ name: 'test-network' });
expect(createNetworkStep)
.to.have.property('target')
.that.has.property('config')
.that.deep.includes({
labels: { TEST: 'TEST', 'io.balena.app-id': '1' },
});
});
it('should kill dependencies of networks before removing', async () => {
const current = createApp({
appUuid: 'deadbeef',
services: [
await createService({
appId: 1,
appUuid: 'deadbeef',
serviceName: 'test',
composition: { networks: ['test-network'] },
}),
],
networks: [
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
],
});
const target = createApp({
appUuid: 'deadbeef',
services: [
await createService({ appUuid: 'deadbeef', serviceName: 'test' }),
],
networks: [],
isTarget: true,
});
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' });
// 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 () => {
const current = createApp({
services: [
await createService({
serviceName: 'test',
composition: { networks: { 'test-network': {} } },
}),
],
networks: [Network.fromComposeObject('test-network', 1, 'appuuid', {})],
});
const target = createApp({
services: [
await createService({
serviceName: 'test',
composition: { networks: { 'test-network': {} } },
}),
],
networks: [
Network.fromComposeObject('test-network', 1, 'appuuid', {
labels: { test: 'test' },
}),
],
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']);
// 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 always kill dependencies of networks before removing', async () => {
const current = createApp({
services: [
// The device for some reason is already running some services
// of the new release, but we need to kill it anyways
await createService({
image: 'alpine',
serviceName: 'one',
commit: 'deadca1f',
composition: { command: 'sleep infinity', networks: ['default'] },
}),
],
networks: [Network.fromComposeObject('default', 1, 'appuuid', {})],
});
const target = createApp({
services: [
await createService({
image: 'alpine',
serviceName: 'one',
commit: 'deadca1f',
composition: { command: 'sleep infinity', networks: ['default'] },
}),
await createService({
image: 'alpine',
serviceName: 'two',
commit: 'deadca1f',
composition: {
command: 'sh -c "echo two && sleep infinity"',
networks: ['default'],
},
}),
],
networks: [
Network.fromComposeObject('default', 1, 'appuuid', {
labels: { test: 'test' },
}),
],
isTarget: true,
});
const availableImages = [
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 [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 () => {
const current = createApp({
services: [
await createService({
image: 'alpine',
serviceName: 'one',
commit: 'deadbeef',
composition: { command: 'sleep infinity', networks: ['default'] },
}),
await createService({
image: 'alpine',
serviceName: 'two',
commit: 'deadbeef',
composition: { command: 'sleep infinity', networks: ['default'] },
}),
],
networks: [Network.fromComposeObject('default', 1, 'appuuid', {})],
});
const target = createApp({
services: [
await createService({
image: 'alpine',
serviceName: 'one',
commit: 'deadca1f',
composition: { command: 'sleep infinity', networks: ['default'] },
}),
await createService({
image: 'alpine',
serviceName: 'two',
commit: 'deadca1f',
composition: {
command: 'sh -c "echo two && sleep infinity"',
networks: ['default'],
},
}),
],
networks: [
Network.fromComposeObject('default', 1, 'appuuid', {
labels: { test: 'test' },
}),
],
isTarget: true,
});
const availableImages = [
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 [lockStep] = expectSteps('takeLock', steps);
expect(lockStep)
.to.have.property('services')
.that.deep.includes.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', () => {
const current = createApp({ networks: [] });
const target = createApp({ networks: [], isTarget: true });
const steps = current.nextStepsForAppUpdate(defaultContext, target);
// A default network should always be created
const [createNetworkStep] = expectSteps('createNetwork', steps);
expect(createNetworkStep)
.to.have.property('target')
.that.deep.includes({ name: 'default' });
});
it('should not create the default network if it already exists', () => {
const current = createApp({
networks: [Network.fromComposeObject('default', 1, 'deadbeef', {})],
});
const target = createApp({ networks: [], isTarget: true });
const steps = current.nextStepsForAppUpdate(defaultContext, target);
// The network should not be created again
expectNoStep('createNetwork', steps);
});
it('should create a config-only network if network_mode is host for all services', async () => {
const svcOne = await createService({
appId: 1,
serviceName: 'one',
composition: { network_mode: 'host' },
});
const svcTwo = await createService({
appId: 1,
serviceName: 'two',
composition: { network_mode: 'host' },
});
const current = createApp({
services: [svcOne, svcTwo],
networks: [],
});
const target = createApp({
services: [svcOne, svcTwo],
networks: [],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [createNetworkStep] = expectSteps('createNetwork', steps);
expect(createNetworkStep)
.to.have.property('target')
.that.has.property('config')
.that.deep.includes({ configOnly: true });
});
it('should not create a config-only network if network_mode: host is not specified for any service', async () => {
const svcOne = await createService({
appId: 1,
serviceName: 'one',
composition: { network_mode: 'host' },
});
const svcTwo = await createService({
appId: 1,
serviceName: 'two',
});
const current = createApp({
services: [svcOne, svcTwo],
networks: [],
});
const target = createApp({
services: [svcOne, svcTwo],
networks: [],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [createNetworkStep] = expectSteps('createNetwork', steps);
expect(createNetworkStep)
.to.have.property('target')
.that.has.property('config')
.that.deep.includes({ configOnly: false });
});
it('should create a config-only network if there are no services in the app', () => {
const current = createApp({});
const target = createApp({ isTarget: true });
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [createNetworkStep] = expectSteps('createNetwork', steps);
expect(createNetworkStep)
.to.have.property('target')
.that.has.property('config')
.that.deep.includes({ configOnly: true });
});
});
describe('service state behavior', () => {
it('should create a kill step for a service which is no longer referenced', async () => {
const current = createApp({
services: [
await createService({ appId: 1, serviceName: 'main' }),
await createService({ appId: 1, serviceName: 'aux' }),
],
networks: [DEFAULT_NETWORK],
});
const target = createApp({
services: [await createService({ appId: 1, serviceName: 'main' })],
networks: [DEFAULT_NETWORK],
isTarget: true,
});
// Take lock first
const steps = current.nextStepsForAppUpdate(
{
...defaultContext,
// With default download-then-kill, a kill should be inferred
// once all images in target state are available
availableImages: [createImage({ serviceName: 'main' })],
},
target,
);
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' });
});
it('should emit a noop when a service which is no longer referenced is already stopping', async () => {
const current = createApp({
services: [
await createService(
{ serviceName: 'main' },
{ state: { status: 'Stopping' } },
),
],
});
const target = createApp({ services: [], isTarget: true });
const steps = current.nextStepsForAppUpdate(defaultContext, target);
expectSteps('noop', steps);
// Kill was already emitted for this service
expectNoStep('kill', steps);
});
it('should emit a noop while waiting on a stopping service', async () => {
const current = createApp({
services: [
await createService(
{ serviceName: 'main', running: true },
{ state: { status: 'Stopping' } },
),
],
});
const target = createApp({
services: [await createService({ serviceName: 'main', running: true })],
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
expectSteps('noop', steps);
});
it('should remove a dead container that is still referenced in the target state', async () => {
const current = createApp({
services: [
await createService(
{ serviceName: 'main' },
{ state: { status: 'Dead' } },
),
],
});
const target = createApp({
services: [await createService({ serviceName: 'main' })],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [removeStep] = expectSteps('remove', steps);
expect(removeStep)
.to.have.property('current')
.to.deep.include({ serviceName: 'main' });
});
it('should remove a dead container that is not referenced in the target state', async () => {
const current = createApp({
services: [
await createService(
{ serviceName: 'main' },
{ state: { status: 'Dead' } },
),
],
});
const target = createApp({ services: [], isTarget: true });
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [removeStep] = expectSteps('remove', steps);
expect(removeStep)
.to.have.property('current')
.to.deep.include({ serviceName: 'main' });
});
it('should emit a noop when a service has an image downloading', async () => {
const current = createApp({ services: [] });
const target = createApp({
services: [
await createService({ image: 'main-image', serviceName: 'main' }),
],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(
{ ...defaultContext, ...{ downloading: ['main-image'] } },
target,
);
expectSteps('noop', steps);
expectNoStep('fetch', steps);
});
it('should emit a takeLock followed by an updateMetadata step when a service has not changed but the release has', async () => {
const current = createApp({
services: [
await createService({
serviceName: 'main',
appId: 1,
commit: 'old-release',
}),
],
networks: [DEFAULT_NETWORK],
});
const target = createApp({
services: [
await createService({
serviceName: 'main',
appId: 1,
commit: 'new-release',
}),
],
networks: [DEFAULT_NETWORK],
isTarget: true,
});
// Take lock before updating metadata
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [takeLockStep] = expectSteps('takeLock', steps);
expect(takeLockStep)
.to.have.property('services')
.that.deep.equals(['main']);
// Infer updateMetadata after locks are taken
const steps2 = current.nextStepsForAppUpdate(
{
...defaultContext,
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
target,
);
const [updateMetadataStep] = expectSteps('updateMetadata', steps2);
expect(updateMetadataStep)
.to.have.property('current')
.to.deep.include({ serviceName: 'main', commit: 'old-release' });
expect(updateMetadataStep)
.to.have.property('target')
.to.deep.include({ serviceName: 'main', commit: 'new-release' });
});
it('should stop a container which has `running: false` as its target', async () => {
const current = createApp({
services: [await createService({ serviceName: 'main' })],
});
const target = createApp({
services: [
await createService({ running: false, serviceName: 'main' }),
],
isTarget: true,
});
// Take lock first
const steps = current.nextStepsForAppUpdate(defaultContext, target);
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' });
});
it('should not try to start a container which has exited and has restart policy of no', async () => {
// Container is a "run once" type of service so it has exitted.
const current = createApp({
services: [
await createService(
{ composition: { restart: 'no' }, running: false },
{ state: { containerId: 'run_once' } },
),
],
});
// Now test that another start step is not added on this service
const target = createApp({
services: [
await createService(
{ composition: { restart: 'no' }, running: false },
{ state: { containerId: 'run_once' } },
),
],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
expectNoStep('start', steps);
});
it('should recreate a container if the target configuration changes', async () => {
const contextWithImages = {
...defaultContext,
...{
availableImages: [
createImage({ appId: 1, serviceName: 'main', name: 'main-image' }),
],
},
};
const current = createApp({
services: [await createService({ appId: 1, serviceName: 'main' })],
// Default network was already created
networks: [DEFAULT_NETWORK],
});
const target = createApp({
services: [
await createService({
appId: 1,
serviceName: 'main',
composition: { privileged: true },
}),
],
networks: [DEFAULT_NETWORK],
isTarget: true,
});
// 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,
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
target,
);
const [killStep] = expectSteps('kill', stepsToIntermediate);
expect(killStep)
.to.have.property('current')
.that.deep.includes({ serviceName: 'main' });
// Assume the intermediate step has already removed the app
const intermediate = createApp({
services: [],
// Default network was already created
networks: [DEFAULT_NETWORK],
});
// Then start
const stepsToTarget = intermediate.nextStepsForAppUpdate(
{
...contextWithImages,
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
target,
);
const [startStep] = expectSteps('start', stepsToTarget);
expect(startStep)
.to.have.property('target')
.that.deep.includes({ serviceName: 'main' });
expect(startStep)
.to.have.property('target')
.that.has.property('config')
.that.deep.includes({ privileged: true });
});
it('should not start a container when it depends on a service which is being installed', async () => {
const current = createApp({
services: [
await createService(
{
running: false,
appId: 1,
serviceName: 'dep',
},
{
state: {
status: 'Installing',
containerId: 'dep-id',
},
},
),
],
networks: [DEFAULT_NETWORK],
});
const target = createApp({
services: [
await createService({
appId: 1,
serviceName: 'main',
composition: {
depends_on: ['dep'],
},
}),
await createService({
appId: 1,
serviceName: 'dep',
}),
],
networks: [DEFAULT_NETWORK],
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,
);
const [startStep] = expectSteps('start', stepsToIntermediate);
expect(startStep)
.to.have.property('target')
.that.deep.includes({ serviceName: 'dep' });
// we now make our current state have the 'dep' service as started...
const intermediate = createApp({
services: [
await createService(
{ appId: 1, serviceName: 'dep' },
{ state: { containerId: 'dep-id' } },
),
],
networks: [DEFAULT_NETWORK],
});
// we should now see a start for the 'main' service...
const stepsToTarget = intermediate.nextStepsForAppUpdate(
{ ...contextWithImages, ...{ containerIds: { dep: 'dep-id' } } },
target,
);
const [startMainStep] = expectSteps('start', stepsToTarget);
expect(startMainStep)
.to.have.property('target')
.that.deep.includes({ serviceName: 'main' });
});
it('should not create a start step when all that changes is a running state', async () => {
const contextWithImages = {
...defaultContext,
...{
availableImages: [
createImage({ appId: 1, name: 'main-image', serviceName: 'main' }),
],
},
};
const current = createApp({
services: [
await createService({ running: false, serviceName: 'main' }),
],
networks: [DEFAULT_NETWORK],
});
const target = createApp({
services: [await createService({ serviceName: 'main' })],
networks: [DEFAULT_NETWORK],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(contextWithImages, target);
// There should be no steps since the engine manages restart policy for stopped containers
expect(steps.length).to.equal(0);
});
it('should create a kill step when a service release has to be updated but the strategy is kill-then-download', async () => {
const contextWithImages = {
...defaultContext,
...{
availableImages: [
createImage({ appId: 1, name: 'main-image', serviceName: 'main' }),
],
},
};
const labels = {
'io.balena.update.strategy': 'kill-then-download',
};
const current = createApp({
services: [
await createService({
labels,
image: 'main-image',
serviceName: 'main',
commit: 'old-release',
}),
],
networks: [DEFAULT_NETWORK],
});
const target = createApp({
services: [
await createService({
labels,
image: 'main-image-2',
serviceName: 'main',
commit: 'new-release',
}),
],
networks: [DEFAULT_NETWORK],
isTarget: true,
});
// 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')
.that.deep.includes({ serviceName: 'main' });
// assume steps were applied
const intermediate = createApp({
services: [],
networks: [DEFAULT_NETWORK],
});
const stepsToTarget = intermediate.nextStepsForAppUpdate(
{
...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,
);
const [fetchStep] = expectSteps('fetch', stepsToTarget);
expect(fetchStep)
.to.have.property('image')
.that.deep.includes({ name: 'main-image-2' });
});
it('should not infer a kill step with the default strategy if a dependency is not downloaded', async () => {
const contextWithImages = {
...defaultContext,
...{
downloading: ['dep-image-2'], // The depended service image is being downloaded
availableImages: [
createImage({ appId: 1, name: 'main-image', serviceName: 'main' }),
createImage({ appId: 1, name: 'dep-image', serviceName: 'dep' }),
createImage({
appId: 1,
name: 'main-image-2',
serviceName: 'main',
}),
],
},
};
const current = createApp({
services: [
await createService({
image: 'main-image',
appId: 1,
serviceName: 'main',
commit: 'old-release',
composition: {
depends_on: ['dep'],
},
}),
await createService({
image: 'dep-image',
appId: 1,
serviceName: 'dep',
commit: 'old-release',
}),
],
networks: [DEFAULT_NETWORK],
});
const target = createApp({
services: [
await createService({
image: 'main-image-2',
appId: 1,
serviceName: 'main',
commit: 'new-release',
composition: {
depends_on: ['dep'],
},
}),
await createService({
image: 'dep-image-2',
appId: 1,
serviceName: 'dep',
commit: 'new-release',
}),
],
networks: [DEFAULT_NETWORK],
isTarget: true,
});
// No kill steps should be generated
const steps = current.nextStepsForAppUpdate(contextWithImages, target);
expectNoStep('kill', steps);
});
it('should create several kill steps as long as there are unmet dependencies', async () => {
const contextWithImages = {
...defaultContext,
...{
availableImages: [
createImage({ appId: 1, name: 'main-image', serviceName: 'main' }),
createImage({
appId: 1,
name: 'main-image-2',
serviceName: 'main',
}),
],
},
};
const current = createApp({
services: [
await createService({
image: 'main-image',
serviceName: 'main',
commit: 'old-release',
}),
],
networks: [DEFAULT_NETWORK],
});
const target = createApp({
services: [
await createService({
image: 'main-image-2',
// new release as target
serviceName: 'main',
commit: 'new-release',
}),
],
networks: [DEFAULT_NETWORK],
isTarget: true,
});
// 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' });
// 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,
// Mock locks taken from previous step
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
},
target,
);
const [newKillStep] = expectSteps('kill', stepsSecondTry);
expect(newKillStep)
.to.have.property('current')
.that.deep.includes({ serviceName: 'main' });
});
it('should create a kill step when a service config has to be updated but the strategy is kill-then-download', async () => {
const labels = {
'io.balena.update.strategy': 'kill-then-download',
};
const current = createApp({
services: [await createService({ serviceName: 'test', labels })],
});
const target = createApp({
services: [
await createService({
serviceName: 'test',
labels,
composition: {
privileged: true,
},
}),
],
isTarget: true,
});
// Take lock first
const steps = current.nextStepsForAppUpdate(defaultContext, target);
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' });
});
it('should not start a service when a network it depends on is not ready', async () => {
const current = createApp({ networks: [DEFAULT_NETWORK] });
const target = createApp({
services: [
await createService({
composition: { networks: ['test'] },
appId: 1,
}),
],
networks: [
DEFAULT_NETWORK,
Network.fromComposeObject('test', 1, 'appuuid', {}),
],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [createNetworkStep] = expectSteps('createNetwork', steps);
expect(createNetworkStep)
.to.have.property('target')
.that.deep.includes({ name: 'test' });
// service should not be created yet
expectNoStep('start', steps);
});
it('should create several kill steps as long as there are no unmet dependencies', async () => {
const current = createApp({
services: [
await createService({
appId: 1,
serviceName: 'one',
commit: 'old-release',
}),
await createService({
appId: 1,
serviceName: 'two',
commit: 'old-release',
}),
await createService({
appId: 1,
serviceName: 'three',
commit: 'old-release',
}),
],
networks: [DEFAULT_NETWORK],
});
const target = createApp({
services: [
await createService({
appId: 1,
serviceName: 'three',
commit: 'new-release',
}),
],
networks: [DEFAULT_NETWORK],
isTarget: true,
});
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(
{
...contextWithImages,
// Mock locks already taken
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['one', 'two', 'three'] },
]),
},
target,
);
expectSteps('kill', steps2, 2);
});
it('should not infer a kill step with the default strategy before all target images have been downloaded', async () => {
const contextWithImages = {
...defaultContext,
...{
downloading: ['other-image-2'], // One of the images is being downloaded
availableImages: [
createImage({ appId: 1, name: 'main-image', serviceName: 'main' }),
createImage({
appId: 1,
name: 'other-image',
serviceName: 'other',
}),
createImage({
appId: 1,
name: 'main-image-2',
serviceName: 'main',
}),
],
},
};
const current = createApp({
services: [
await createService({
image: 'main-image',
appId: 1,
serviceName: 'main',
commit: 'old-release',
}),
await createService({
image: 'other-image',
appId: 1,
serviceName: 'other',
commit: 'old-release',
}),
],
networks: [DEFAULT_NETWORK],
});
const target = createApp({
services: [
await createService({
image: 'main-image-2',
appId: 1,
serviceName: 'main',
commit: 'new-release',
}),
await createService({
image: 'other-image-2',
appId: 1,
serviceName: 'other',
commit: 'new-release',
}),
],
networks: [DEFAULT_NETWORK],
isTarget: true,
});
// No kill steps should be generated
const steps = current.nextStepsForAppUpdate(contextWithImages, target);
expectNoStep('kill', steps);
});
it('should not infer a start step before all target images have been downloaded', async () => {
const contextWithImages = {
...defaultContext,
...{
downloading: ['other-image'], // One of the images is being downloaded
availableImages: [
createImage({ appId: 1, name: 'main-image', serviceName: 'main' }),
],
},
};
const current = createApp({
services: [],
networks: [DEFAULT_NETWORK],
});
const target = createApp({
services: [
await createService({
image: 'main-image',
appId: 1,
serviceName: 'main',
commit: 'new-release',
}),
await createService({
image: 'other-image',
appId: 1,
serviceName: 'other',
commit: 'new-release',
}),
],
networks: [DEFAULT_NETWORK],
isTarget: true,
});
// No kill steps should be generated
const steps = current.nextStepsForAppUpdate(contextWithImages, target);
expectNoStep('start', steps);
});
it('should infer a start step only when target images have been downloaded', async () => {
const current = createApp({
services: [],
networks: [DEFAULT_NETWORK],
});
const target = createApp({
services: [
await createService({
image: 'main-image',
appId: 1,
serviceName: 'main',
commit: 'new-release',
}),
await createService({
image: 'other-image',
appId: 1,
serviceName: 'other',
commit: 'new-release',
}),
],
networks: [DEFAULT_NETWORK],
isTarget: true,
});
// 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);
});
});
describe('image state behavior', () => {
it('should emit a fetch step when an image has not been downloaded for a service', async () => {
const current = createApp({ services: [] });
const target = createApp({
services: [await createService({ serviceName: 'main' })],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [fetchStep] = expectSteps('fetch', steps);
expect(fetchStep)
.to.have.property('image')
.that.deep.includes({ serviceName: 'main' });
});
it('should not infer a fetch step when the download is already in progress', async () => {
const contextWithDownloading = {
...defaultContext,
...{
downloading: ['image2'],
},
};
const current = createApp({ services: [] });
const target = createApp({
services: [
await createService({ image: 'image2', serviceName: 'main' }),
],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(
contextWithDownloading,
target,
);
expectNoStep('fetch', steps);
});
it('should not infer a kill step with the default strategy if a dependency is not downloaded', async () => {
const current = createApp({
services: [await createService({ image: 'image1' })],
});
const target = createApp({
services: [await createService({ image: 'image2' })],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [fetchStep] = expectSteps('fetch', steps);
expect(fetchStep)
.to.have.property('image')
.that.deep.includes({ name: 'image2' });
expectNoStep('kill', steps);
});
});
describe('update lock state behavior', () => {
it('should infer a releaseLock step if there are locks to be released before settling target state', async () => {
const services = [
await createService({ serviceName: 'server' }),
await createService({ serviceName: 'client' }),
];
const current = createApp({
services,
networks: [DEFAULT_NETWORK],
});
const target = createApp({
services,
networks: [DEFAULT_NETWORK],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(
{
...defaultContext,
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['server', 'client'] },
]),
},
target,
);
const [releaseLockStep] = expectSteps('releaseLock', steps, 1);
expect(releaseLockStep).to.have.property('appId').that.equals(1);
// Even if not all the locks are taken, releaseLock should be inferred
const steps2 = current.nextStepsForAppUpdate(
{
...defaultContext,
locksTaken: new LocksTakenMap([{ appId: 1, services: ['server'] }]),
},
target,
);
const [releaseLockStep2] = expectSteps('releaseLock', steps2, 1);
expect(releaseLockStep2).to.have.property('appId').that.equals(1);
});
it('should not infer a releaseLock step if there are no locks to be released', async () => {
const services = [
await createService({ serviceName: 'server' }),
await createService({ serviceName: 'client' }),
];
const current = createApp({
services,
networks: [DEFAULT_NETWORK],
});
const target = createApp({
services,
networks: [DEFAULT_NETWORK],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
expect(steps).to.have.length(0);
});
it('should infer a releaseLock step for the current appId only', async () => {
const services = [
await createService({ serviceName: 'server' }),
await createService({ serviceName: 'client' }),
];
const current = createApp({
services,
networks: [DEFAULT_NETWORK],
});
const target = createApp({
services,
networks: [DEFAULT_NETWORK],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(
{
...defaultContext,
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['server', 'client'] },
{ appId: 2, services: ['main'] },
]),
},
target,
);
const [releaseLockStep] = expectSteps('releaseLock', steps, 1);
expect(releaseLockStep).to.have.property('appId').that.equals(1);
});
});
});