balena-supervisor/test/unit/compose/app.spec.ts
Felipe Lalanne 967cb7747f Make local mode image management work as in cloud mode
There were multiple places in the state engine that skipped some
operations while in local mode. In reality, all it's needed while in
local mode is to skip image and volume deletion.

This commit simplifies application-manager and compose app to be more
local mode agnostic and instead making the image deletion and volume
deletion configurable via function arguments.

This also has the benefit to make the treatment of local mode
applications more similar to cloud mode applications, allowing for
API endpoints to function the same way both modes.

Change-type: patch
2023-04-20 14:58:58 -04:00

1611 lines
43 KiB
TypeScript

import { expect } from 'chai';
import App from '~/src/compose/app';
import {
CompositionStep,
CompositionStepAction,
} from '~/src/compose/composition-steps';
import { Image } from '~/src/compose/images';
import Network from '~/src/compose/network';
import Service from '~/src/compose/service';
import { ServiceComposeConfig } from '~/src/compose/types/service';
import Volume from '~/src/compose/volume';
const defaultContext = {
keepVolumes: false,
availableImages: [] as Image[],
containerIds: {},
downloading: [] as string[],
};
function createApp({
services = [] as Service[],
networks = [] as Network[],
volumes = [] as Volume[],
isTarget = false,
appId = 1,
appUuid = 'appuuid',
} = {}) {
return new App(
{
appId,
appUuid,
services,
networks,
volumes,
},
isTarget,
);
}
async function createService(
{
appId = 1,
appUuid = 'appuuid',
serviceName = 'test',
commit = 'test-commit',
...conf
} = {} as Partial<ServiceComposeConfig>,
{ state = {} as Partial<Service>, options = {} as any } = {},
) {
const svc = await Service.fromComposeObject(
{
appId,
appUuid,
serviceName,
commit,
running: true,
...conf,
},
options,
);
// Add additonal configuration
for (const k of Object.keys(state)) {
(svc as any)[k] = (state as any)[k];
}
return svc;
}
function createImage(
{
appId = 1,
appUuid = 'appuuid',
name = 'test-image',
serviceName = 'test',
commit = 'test-commit',
...extra
} = {} as Partial<Image>,
) {
return {
appId,
appUuid,
commit,
name,
serviceName,
...extra,
} as Image;
}
const expectSteps = (
action: CompositionStepAction,
steps: CompositionStep[],
min = 1,
max = min,
message = `Expected to find ${min} step(s) with action '${action}', instead found ${JSON.stringify(
steps.map((s) => s.action),
)}`,
) => {
const filtered = steps.filter((s) => s.action === action);
if (filtered.length < min || filtered.length > max) {
throw new Error(message);
}
return filtered;
};
function expectNoStep(action: CompositionStepAction, steps: CompositionStep[]) {
expectSteps(action, steps, 0, 0);
}
const defaultNetwork = Network.fromComposeObject('default', 1, 'appuuid', {});
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({
composition: { volumes: ['test-volume:/data'] },
}),
],
volumes: [Volume.fromComposeObject('test-volume', 1, 'deadbeef')],
});
const target = createApp({
services: [
await createService({
composition: { volumes: ['test-volume:/data'] },
}),
],
volumes: [
Volume.fromComposeObject('test-volume', 1, 'deadbeef', {
labels: { test: 'test' },
}),
],
isTarget: true,
});
// Calculate steps
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [killStep] = expectSteps('kill', steps);
expect(killStep)
.to.have.property('current')
.that.deep.includes({ serviceName: 'test' });
});
it('should correctly infer to remove an app volumes when the app is being removed', async () => {
const current = createApp({
volumes: [Volume.fromComposeObject('test-volume', 1, 'deadbeef')],
});
const steps = await 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({
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: [defaultNetwork],
isTarget: true,
});
// Generate initial state with one service & one volume
const current = createApp({
services: [service],
networks: [defaultNetwork],
volumes: [volume],
});
// Step 1: kill
const steps = current.nextStepsForAppUpdate(
contextWithImages,
intermediateTarget,
);
expectSteps('kill', steps);
// Step 2: noop (service is stopping)
service.status = 'Stopping';
const secondStageSteps = current.nextStepsForAppUpdate(
contextWithImages,
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: [defaultNetwork],
volumes: [volume],
});
expect(
currentWithServiceRemoved.nextStepsForAppUpdate(
contextWithImages,
intermediateTarget,
),
).to.have.length(0);
// Simulate volume removal
const currentWithVolumesRemoved = createApp({
services: [],
networks: [defaultNetwork],
volumes: [],
});
// Step 3: createVolume
service.status = 'Running';
const target = createApp({
services: [service],
networks: [defaultNetwork],
volumes: [volume],
isTarget: true,
});
const recreateVolumeSteps =
currentWithVolumesRemoved.nextStepsForAppUpdate(
contextWithImages,
target,
);
expect(recreateVolumeSteps).to.have.length(1);
expectSteps('createVolume', recreateVolumeSteps);
// Final step: start service
const currentWithVolumeRecreated = createApp({
services: [],
networks: [defaultNetwork],
volumes: [volume],
});
const createServiceSteps =
currentWithVolumeRecreated.nextStepsForAppUpdate(
contextWithImages,
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: [defaultNetwork, defaultNetwork],
});
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: [
defaultNetwork,
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
],
services: [
await createService({
appId: 1,
appUuid: 'deadbeef',
composition: { networks: ['test-network'] },
}),
],
});
const target = createApp({
networks: [
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
],
services: [
await createService({
appId: 1,
appUuid: 'deadbeef',
composition: { networks: ['test-network'] },
}),
],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [removeNetworkStep] = expectSteps('kill', steps);
expect(removeNetworkStep).to.have.property('current').that.deep.includes({
serviceName: 'test',
});
});
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',
composition: { networks: ['test-network'] },
}),
],
networks: [
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
],
});
const target = createApp({
appUuid: 'deadbeef',
services: [await createService({ appUuid: 'deadbeef' })],
networks: [],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [killStep] = expectSteps('kill', steps);
expect(killStep)
.to.have.property('current')
.that.deep.includes({ serviceName: 'test' });
});
it('should kill dependencies of networks before changing config', async () => {
const current = createApp({
services: [
await createService({
composition: { networks: ['test-network'] },
}),
],
networks: [Network.fromComposeObject('test-network', 1, 'appuuid', {})],
});
const target = createApp({
services: [
await createService({
composition: { networks: { 'test-network': {} } },
}),
],
networks: [
Network.fromComposeObject('test-network', 1, 'appuuid', {
labels: { test: 'test' },
}),
],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [killStep] = expectSteps('kill', steps);
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
expectNoStep('removeNetwork', steps);
});
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', async () => {
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: [defaultNetwork],
});
const target = createApp({
services: [await createService({ appId: 1, serviceName: 'main' })],
networks: [defaultNetwork],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [killStep] = expectSteps('kill', steps);
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 an updateMetadata step when a service has not changed but the release has', async () => {
const current = createApp({
services: [
await createService({ serviceName: 'main', commit: 'old-release' }),
],
});
const target = createApp({
services: [
await createService({ serviceName: 'main', commit: 'new-release' }),
],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [updateMetadataStep] = expectSteps('updateMetadata', steps);
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,
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [stopStep] = expectSteps('stop', steps);
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: [defaultNetwork],
});
const target = createApp({
services: [
await createService({
appId: 1,
serviceName: 'main',
composition: { privileged: true },
}),
],
networks: [defaultNetwork],
isTarget: true,
});
// should see a 'stop'
const stepsToIntermediate = current.nextStepsForAppUpdate(
contextWithImages,
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: [defaultNetwork],
});
// now should see a 'start'
const stepsToTarget = intermediate.nextStepsForAppUpdate(
contextWithImages,
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 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(
{
running: false,
appId: 1,
serviceName: 'dep',
},
{
state: {
status: 'Installing',
containerId: 'dep-id',
},
},
),
],
networks: [defaultNetwork],
});
const target = createApp({
services: [
await createService({
appId: 1,
serviceName: 'main',
composition: {
depends_on: ['dep'],
},
}),
await createService({
appId: 1,
serviceName: 'dep',
}),
],
networks: [defaultNetwork],
isTarget: true,
});
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')
.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: [defaultNetwork],
});
// 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: [defaultNetwork],
});
const target = createApp({
services: [await createService({ serviceName: 'main' })],
networks: [defaultNetwork],
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: [defaultNetwork],
});
const target = createApp({
services: [
await createService({
labels,
image: 'main-image-2',
serviceName: 'main',
commit: 'new-release',
}),
],
networks: [defaultNetwork],
isTarget: true,
});
const stepsToIntermediate = current.nextStepsForAppUpdate(
contextWithImages,
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: [defaultNetwork],
});
const stepsToTarget = intermediate.nextStepsForAppUpdate(
contextWithImages,
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: [defaultNetwork],
});
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: [defaultNetwork],
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: [defaultNetwork],
});
const target = createApp({
services: [
await createService({
image: 'main-image-2',
// new release as target
serviceName: 'main',
commit: 'new-release',
}),
],
networks: [defaultNetwork],
isTarget: true,
});
const stepsFirstTry = current.nextStepsForAppUpdate(
contextWithImages,
target,
);
const [killStep] = expectSteps('kill', stepsFirstTry);
expect(killStep)
.to.have.property('current')
.that.deep.includes({ serviceName: 'main' });
// if at first you don't succeed
const stepsSecondTry = current.nextStepsForAppUpdate(
contextWithImages,
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')
.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({ labels })],
});
const target = createApp({
services: [
await createService({
labels,
composition: {
privileged: true,
},
}),
],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [killStep] = expectSteps('kill', steps);
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: [defaultNetwork] });
const target = createApp({
services: [
await createService({
composition: { networks: ['test'] },
appId: 1,
}),
],
networks: [
defaultNetwork,
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',
}),
],
});
const target = createApp({
services: [
await createService({
appId: 1,
serviceName: 'three',
commit: 'new-release',
}),
],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(defaultContext, target);
expectSteps('kill', steps, 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: [defaultNetwork],
});
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: [defaultNetwork],
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: [defaultNetwork],
});
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: [defaultNetwork],
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 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: [defaultNetwork],
});
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: [defaultNetwork],
isTarget: true,
});
// No kill steps should be generated
const steps = current.nextStepsForAppUpdate(contextWithImages, target);
expectSteps('start', steps, 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);
});
});
});