mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2024-12-30 18:47:00 +00:00
31effed426
Using safeStateClone within doPurge to applyIntermediateTarget after successful volume purge has led to various type deficiencies being revealed in common.js. Add several inline types in common.js to satisfy the type checker (credit: Page <page@balena.io>). Delete common.d.ts since it's not required and might mistakenly mask true I/O types of functions in common.js. Closes: #1611 Change-type: patch Signed-off-by: Christina Wang <christina@balena.io>
1186 lines
29 KiB
TypeScript
1186 lines
29 KiB
TypeScript
import * as _ from 'lodash';
|
|
import { expect } from 'chai';
|
|
|
|
import * as appMock from './lib/application-state-mock';
|
|
|
|
import * as applicationManager from '../src/compose/application-manager';
|
|
import * as deviceState from '../src/device-state';
|
|
import App from '../src/compose/app';
|
|
import * as config from '../src/config';
|
|
import * as dbFormat from '../src/device-state/db-format';
|
|
|
|
import Service from '../src/compose/service';
|
|
import Network from '../src/compose/network';
|
|
import Volume from '../src/compose/volume';
|
|
import {
|
|
CompositionStep,
|
|
CompositionStepAction,
|
|
} from '../src/compose/composition-steps';
|
|
import { ServiceComposeConfig } from '../src/compose/types/service';
|
|
import { Image } from '../src/compose/images';
|
|
import { inspect } from 'util';
|
|
|
|
const defaultContext = {
|
|
localMode: false,
|
|
availableImages: [],
|
|
containerIds: {},
|
|
downloading: [],
|
|
};
|
|
|
|
function createApp(
|
|
services: Service[],
|
|
networks: Network[],
|
|
volumes: Volume[],
|
|
target: boolean,
|
|
appId = 1,
|
|
) {
|
|
return new App(
|
|
{
|
|
appId,
|
|
services,
|
|
networks: _.keyBy(networks, 'name'),
|
|
volumes: _.keyBy(volumes, 'name'),
|
|
},
|
|
target,
|
|
);
|
|
}
|
|
|
|
async function createService(
|
|
conf: Partial<ServiceComposeConfig>,
|
|
appId = 1,
|
|
serviceName = 'test',
|
|
releaseId = 2,
|
|
serviceId = 3,
|
|
imageId = 4,
|
|
extraState?: Partial<Service>,
|
|
) {
|
|
const svc = await Service.fromComposeObject(
|
|
{
|
|
appId,
|
|
serviceName,
|
|
releaseId,
|
|
serviceId,
|
|
imageId,
|
|
...conf,
|
|
},
|
|
{} as any,
|
|
);
|
|
if (extraState != null) {
|
|
for (const k of Object.keys(extraState)) {
|
|
(svc as any)[k] = (extraState as any)[k];
|
|
}
|
|
}
|
|
return svc;
|
|
}
|
|
|
|
type ServicePredicate = string | ((service: Partial<Service>) => boolean);
|
|
type StepTest = Chai.Assertion & {
|
|
asStep?: CompositionStep;
|
|
forCurrent: (predicate: ServicePredicate) => Chai.Assertion;
|
|
forTarget: (predicate: ServicePredicate) => Chai.Assertion;
|
|
};
|
|
|
|
// tslint:disable: no-unused-expression-chai
|
|
function withSteps(steps: CompositionStep[]) {
|
|
return {
|
|
expectStep: (action: CompositionStepAction): StepTest => {
|
|
const matchingSteps = _.filter(steps, (step) => step.action === action);
|
|
|
|
const assertion: Partial<StepTest> = expect(
|
|
matchingSteps,
|
|
`Step for '${action}', not found`,
|
|
);
|
|
|
|
const forService = (
|
|
predicate: ServicePredicate,
|
|
property: 'current' | 'target',
|
|
) => {
|
|
const [firstMatch] = _.filter(matchingSteps, (s) => {
|
|
const t = (s as any)[property];
|
|
if (!t) {
|
|
throw new Error(`${property} is not defined for action ${action}`);
|
|
}
|
|
|
|
if (_.isFunction(predicate)) {
|
|
return predicate(t);
|
|
} else {
|
|
return t.serviceName! === predicate;
|
|
}
|
|
});
|
|
return expect(
|
|
firstMatch,
|
|
`Step for '${action}' matching predicate, not found`,
|
|
);
|
|
};
|
|
|
|
assertion.asStep = matchingSteps[0];
|
|
assertion.forCurrent = (service) => forService(service, 'current');
|
|
assertion.forTarget = (service) => forService(service, 'target');
|
|
|
|
return assertion as StepTest;
|
|
},
|
|
rejectStep: (action: CompositionStepAction) => expectNoStep(action, steps),
|
|
};
|
|
}
|
|
|
|
function expectStep(
|
|
action: CompositionStepAction,
|
|
steps: CompositionStep[],
|
|
): number {
|
|
const idx = _.findIndex(steps, { action });
|
|
if (idx === -1) {
|
|
console.log(inspect({ action, steps }, true, 3, true));
|
|
throw new Error(`Expected to find step with action: ${action}`);
|
|
}
|
|
return idx;
|
|
}
|
|
|
|
function expectNoStep(action: CompositionStepAction, steps: CompositionStep[]) {
|
|
if (_.some(steps, { action })) {
|
|
console.log(inspect({ action, steps }, true, 3, true));
|
|
throw new Error(`Did not expect to find step with action: ${action}`);
|
|
}
|
|
}
|
|
|
|
const defaultNetwork = Network.fromComposeObject('default', 1, {});
|
|
|
|
describe('compose/app', () => {
|
|
before(async () => {
|
|
await config.initialized;
|
|
await applicationManager.initialized;
|
|
});
|
|
beforeEach(() => {
|
|
// Sane defaults
|
|
appMock.mockSupervisorNetwork(true);
|
|
appMock.mockManagers([], [], []);
|
|
appMock.mockImages([], false, []);
|
|
});
|
|
afterEach(() => {
|
|
appMock.unmockAll();
|
|
});
|
|
|
|
it('should correctly infer a volume create step', () => {
|
|
const current = createApp([], [], [], false);
|
|
const target = createApp(
|
|
[],
|
|
[],
|
|
[Volume.fromComposeObject('test-volume', 1, {})],
|
|
true,
|
|
);
|
|
|
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
|
|
const idx = expectStep('createVolume', steps);
|
|
expect(steps[idx])
|
|
.to.have.property('target')
|
|
.that.has.property('name')
|
|
.that.equals('test-volume');
|
|
});
|
|
|
|
it('should correctly infer more than one volume create step', () => {
|
|
const current = createApp([], [], [], false);
|
|
const target = createApp(
|
|
[],
|
|
[],
|
|
[
|
|
Volume.fromComposeObject('test-volume', 1, {}),
|
|
Volume.fromComposeObject('test-volume-2', 1, {}),
|
|
],
|
|
true,
|
|
);
|
|
|
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
let idx = expectStep('createVolume', steps);
|
|
expect(steps[idx])
|
|
.to.have.property('target')
|
|
.that.has.property('name')
|
|
.that.equals('test-volume');
|
|
delete steps[idx];
|
|
idx = expectStep('createVolume', steps);
|
|
expect(steps[idx])
|
|
.to.have.property('target')
|
|
.that.has.property('name')
|
|
.that.equals('test-volume-2');
|
|
});
|
|
|
|
// We don't remove volumes until the end
|
|
it('should correctly not infer a volume remove step when the app is still referenced', () => {
|
|
const current = createApp(
|
|
[],
|
|
[],
|
|
[
|
|
Volume.fromComposeObject('test-volume', 1, {}),
|
|
Volume.fromComposeObject('test-volume-2', 1, {}),
|
|
],
|
|
false,
|
|
);
|
|
const target = createApp(
|
|
[],
|
|
[],
|
|
[Volume.fromComposeObject('test-volume-2', 1, {})],
|
|
true,
|
|
);
|
|
|
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
|
|
expect(() => {
|
|
expectStep('removeVolume', steps);
|
|
}).to.throw();
|
|
});
|
|
|
|
it('should correctly infer volume recreation steps', () => {
|
|
const current = createApp(
|
|
[],
|
|
[],
|
|
[Volume.fromComposeObject('test-volume', 1, {})],
|
|
false,
|
|
);
|
|
const target = createApp(
|
|
[],
|
|
[],
|
|
[
|
|
Volume.fromComposeObject('test-volume', 1, {
|
|
labels: { test: 'test' },
|
|
}),
|
|
],
|
|
true,
|
|
);
|
|
|
|
let steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
|
|
let idx = expectStep('removeVolume', steps);
|
|
expect(steps[idx])
|
|
.to.have.property('current')
|
|
.that.has.property('config')
|
|
.that.has.property('labels')
|
|
.that.deep.equals({ 'io.balena.supervised': 'true' });
|
|
|
|
current.volumes = {};
|
|
steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
idx = expectStep('createVolume', steps);
|
|
expect(steps[idx])
|
|
.to.have.property('target')
|
|
.that.has.property('config')
|
|
.that.has.property('labels')
|
|
.that.deep.equals({ 'io.balena.supervised': 'true', test: 'test' });
|
|
});
|
|
|
|
it('should kill dependencies of a volume before changing config', async () => {
|
|
const current = createApp(
|
|
[await createService({ volumes: ['test-volume'] })],
|
|
[],
|
|
[Volume.fromComposeObject('test-volume', 1, {})],
|
|
false,
|
|
);
|
|
const target = createApp(
|
|
[await createService({ volumes: ['test-volume'] })],
|
|
[],
|
|
[
|
|
Volume.fromComposeObject('test-volume', 1, {
|
|
labels: { test: 'test' },
|
|
}),
|
|
],
|
|
true,
|
|
);
|
|
|
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
|
|
const idx = expectStep('kill', steps);
|
|
expect(steps[idx])
|
|
.to.have.property('current')
|
|
.that.has.property('serviceName')
|
|
.that.equals('test');
|
|
});
|
|
|
|
it('should correctly infer to remove an apps volumes when it is no longer referenced', async () => {
|
|
appMock.mockManagers(
|
|
[],
|
|
[Volume.fromComposeObject('test-volume', 1, {})],
|
|
[],
|
|
);
|
|
appMock.mockImages([], false, []);
|
|
|
|
const origFn = dbFormat.getApps;
|
|
// @ts-expect-error Assigning to a RO property
|
|
dbFormat.getApps = () => Promise.resolve({});
|
|
const target = await deviceState.getTarget();
|
|
|
|
try {
|
|
const steps = await applicationManager.getRequiredSteps(
|
|
target.local.apps,
|
|
);
|
|
expect(steps).to.have.length(1);
|
|
expect(steps[0]).to.have.property('action').that.equals('removeVolume');
|
|
} finally {
|
|
// @ts-expect-error Assigning to a RO property
|
|
dbFormat.getApps = origFn;
|
|
}
|
|
});
|
|
|
|
it('should correctly infer a network create step', () => {
|
|
const current = createApp([], [], [], false);
|
|
const target = createApp(
|
|
[],
|
|
[Network.fromComposeObject('default', 1, {})],
|
|
[],
|
|
true,
|
|
);
|
|
|
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
expectStep('createNetwork', steps);
|
|
});
|
|
|
|
it('should correctly infer a network remove step', () => {
|
|
const current = createApp(
|
|
[],
|
|
[Network.fromComposeObject('test-network', 1, {})],
|
|
[],
|
|
false,
|
|
);
|
|
const target = createApp([], [], [], true);
|
|
|
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
const idx = expectStep('removeNetwork', steps);
|
|
expect(steps[idx])
|
|
.to.have.property('current')
|
|
.that.has.property('name')
|
|
.that.equals('test-network');
|
|
});
|
|
|
|
it('should correctly infer a network recreation step', () => {
|
|
const current = createApp(
|
|
[],
|
|
[Network.fromComposeObject('test-network', 1, {})],
|
|
[],
|
|
false,
|
|
);
|
|
const target = createApp(
|
|
[],
|
|
[
|
|
Network.fromComposeObject('test-network', 1, {
|
|
labels: { TEST: 'TEST' },
|
|
}),
|
|
],
|
|
[],
|
|
true,
|
|
);
|
|
|
|
let steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
let idx = expectStep('removeNetwork', steps);
|
|
expect(steps[idx])
|
|
.to.have.property('current')
|
|
.that.has.property('name')
|
|
.that.equals('test-network');
|
|
|
|
delete current.networks['test-network'];
|
|
steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
idx = expectStep('createNetwork', steps);
|
|
expect(steps[idx])
|
|
.to.have.property('target')
|
|
.that.has.property('name')
|
|
.that.equals('test-network');
|
|
});
|
|
|
|
it('should kill dependencies of networks before removing', async () => {
|
|
const current = createApp(
|
|
[await createService({ networks: { 'test-network': {} } })],
|
|
[Network.fromComposeObject('test-network', 1, {})],
|
|
[],
|
|
false,
|
|
);
|
|
const target = createApp([await createService({})], [], [], true);
|
|
|
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
const idx = expectStep('kill', steps);
|
|
expect(steps[idx])
|
|
.to.have.property('current')
|
|
.that.has.property('serviceName')
|
|
.that.equals('test');
|
|
});
|
|
|
|
it('should kill dependencies of networks before changing config', async () => {
|
|
const current = createApp(
|
|
[await createService({ networks: { 'test-network': {} } })],
|
|
[Network.fromComposeObject('test-network', 1, {})],
|
|
[],
|
|
false,
|
|
);
|
|
const target = createApp(
|
|
[await createService({ networks: { 'test-network': {} } })],
|
|
[
|
|
Network.fromComposeObject('test-network', 1, {
|
|
labels: { test: 'test' },
|
|
}),
|
|
],
|
|
[],
|
|
true,
|
|
);
|
|
|
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
const idx = expectStep('kill', steps);
|
|
expect(steps[idx])
|
|
.to.have.property('current')
|
|
.that.has.property('serviceName')
|
|
.that.equals('test');
|
|
// We shouldn't try to remove the network until we have gotten rid of the dependencies
|
|
expect(() => expectStep('removeNetwork', steps)).to.throw();
|
|
});
|
|
|
|
it('should not output a kill step for a service which is already stopping when changing a volume', async () => {
|
|
const service = await createService({ volumes: ['test-volume'] });
|
|
service.status = 'Stopping';
|
|
const current = createApp(
|
|
[service],
|
|
[],
|
|
[Volume.fromComposeObject('test-volume', 1, {})],
|
|
false,
|
|
);
|
|
const target = createApp(
|
|
[service],
|
|
[],
|
|
[
|
|
Volume.fromComposeObject('test-volume', 1, {
|
|
labels: { test: 'test' },
|
|
}),
|
|
],
|
|
true,
|
|
);
|
|
|
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
expect(() => expectStep('kill', steps)).to.throw();
|
|
});
|
|
|
|
it('should create the default network if it does not exist', () => {
|
|
const current = createApp([], [], [], false);
|
|
const target = createApp([], [], [], true);
|
|
|
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
const idx = expectStep('createNetwork', steps);
|
|
expect(steps[idx])
|
|
.to.have.property('target')
|
|
.that.has.property('name')
|
|
.that.equals('default');
|
|
});
|
|
|
|
it('should create a kill step for service which is no longer referenced', async () => {
|
|
const current = createApp(
|
|
[
|
|
await createService({}, 1, 'main', 1, 1),
|
|
await createService({}, 1, 'aux', 1, 2),
|
|
],
|
|
[Network.fromComposeObject('test-network', 1, {})],
|
|
[],
|
|
false,
|
|
);
|
|
const target = createApp(
|
|
[await createService({}, 1, 'main', 2, 1)],
|
|
[Network.fromComposeObject('test-network', 1, {})],
|
|
[],
|
|
true,
|
|
);
|
|
|
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
const idx = expectStep('kill', steps);
|
|
expect(steps[idx])
|
|
.to.have.property('current')
|
|
.that.has.property('serviceName')
|
|
.that.equals('aux');
|
|
});
|
|
|
|
it('should emit a noop when a service which is no longer referenced is already stopping', async () => {
|
|
const current = createApp(
|
|
[await createService({}, 1, 'main', 1, 1, 1, { status: 'Stopping' })],
|
|
[],
|
|
[],
|
|
false,
|
|
);
|
|
const target = createApp([], [], [], true);
|
|
|
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
expectStep('noop', steps);
|
|
});
|
|
|
|
it('should remove a dead container that is still referenced in the target state', async () => {
|
|
const current = createApp(
|
|
[await createService({}, 1, 'main', 1, 1, 1, { status: 'Dead' })],
|
|
[],
|
|
[],
|
|
false,
|
|
);
|
|
const target = createApp(
|
|
[await createService({}, 1, 'main', 1, 1, 1)],
|
|
[],
|
|
[],
|
|
true,
|
|
);
|
|
|
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
expectStep('remove', steps);
|
|
});
|
|
|
|
it('should remove a dead container that is not referenced in the target state', async () => {
|
|
const current = createApp(
|
|
[await createService({}, 1, 'main', 1, 1, 1, { status: 'Dead' })],
|
|
[],
|
|
[],
|
|
false,
|
|
);
|
|
const target = createApp([], [], [], true);
|
|
|
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
expectStep('remove', steps);
|
|
});
|
|
|
|
it('should emit a noop when a service has an image downloading', async () => {
|
|
const current = createApp([], [], [], false);
|
|
const target = createApp(
|
|
[await createService({}, 1, 'main', 1, 1, 1)],
|
|
[],
|
|
[],
|
|
true,
|
|
);
|
|
|
|
const steps = current.nextStepsForAppUpdate(
|
|
{ ...defaultContext, ...{ downloading: [1] } },
|
|
target,
|
|
);
|
|
expectStep('noop', steps);
|
|
});
|
|
|
|
it('should emit an updateMetadata step when a service has not changed but the release has', async () => {
|
|
const current = createApp(
|
|
[await createService({}, 1, 'main', 1, 1, 1)],
|
|
[],
|
|
[],
|
|
false,
|
|
);
|
|
const target = createApp(
|
|
[await createService({}, 1, 'main', 2, 1, 1)],
|
|
[],
|
|
[],
|
|
true,
|
|
);
|
|
|
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
expectStep('updateMetadata', steps);
|
|
});
|
|
|
|
it('should stop a container which has stoppped as its target', async () => {
|
|
const current = createApp(
|
|
[await createService({}, 1, 'main', 1, 1, 1)],
|
|
[],
|
|
[],
|
|
false,
|
|
);
|
|
const target = createApp(
|
|
[await createService({ running: false }, 1, 'main', 1, 1, 1)],
|
|
[],
|
|
[],
|
|
true,
|
|
);
|
|
|
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
expectStep('stop', steps);
|
|
});
|
|
|
|
it('should not try to start a container which has exitted and has restart policy of no', async () => {
|
|
// Container is a "run once" type of service so it has exitted.
|
|
const current = createApp(
|
|
[
|
|
await createService(
|
|
{ restart: 'no', running: false },
|
|
1,
|
|
'main',
|
|
1,
|
|
1,
|
|
1,
|
|
{ containerId: 'run_once' },
|
|
),
|
|
],
|
|
[],
|
|
[],
|
|
false,
|
|
);
|
|
|
|
// Mark this container as previously being started
|
|
applicationManager.containerStarted['run_once'] = true;
|
|
|
|
// Now test that another start step is not added on this service
|
|
const target = createApp(
|
|
[
|
|
await createService(
|
|
{ restart: 'no', running: true },
|
|
1,
|
|
'main',
|
|
1,
|
|
1,
|
|
1,
|
|
{ containerId: 'run_once' },
|
|
),
|
|
],
|
|
[],
|
|
[],
|
|
true,
|
|
);
|
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
withSteps(steps).rejectStep('start');
|
|
});
|
|
|
|
it('should recreate a container if the target configuration changes', async () => {
|
|
const contextWithImages = {
|
|
...defaultContext,
|
|
...{
|
|
availableImages: [
|
|
{
|
|
appId: 1,
|
|
dependent: 0,
|
|
imageId: 1,
|
|
releaseId: 1,
|
|
serviceId: 1,
|
|
name: 'main-image',
|
|
serviceName: 'main',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
let current = createApp(
|
|
[await createService({}, 1, 'main', 1, 1, 1, {})],
|
|
[defaultNetwork],
|
|
[],
|
|
false,
|
|
);
|
|
const target = createApp(
|
|
[await createService({ privileged: true }, 1, 'main', 1, 1, 1, {})],
|
|
[defaultNetwork],
|
|
[],
|
|
true,
|
|
);
|
|
|
|
// should see a 'stop'
|
|
let steps = current.nextStepsForAppUpdate(contextWithImages, target);
|
|
withSteps(steps).expectStep('stop').to.exist;
|
|
|
|
// remove the service since it's stopped...
|
|
current = createApp([], [defaultNetwork], [], false);
|
|
|
|
// now should see a 'start'
|
|
steps = current.nextStepsForAppUpdate(contextWithImages, target);
|
|
withSteps(steps)
|
|
.expectStep('start')
|
|
.forTarget((t) => t.serviceName === 'main').to.exist;
|
|
});
|
|
|
|
it('should not start a container when it depends on a service which is being installed', async () => {
|
|
const mainImage: Image = {
|
|
appId: 1,
|
|
dependent: 0,
|
|
imageId: 1,
|
|
releaseId: 1,
|
|
serviceId: 1,
|
|
name: 'main-image',
|
|
serviceName: 'main',
|
|
};
|
|
|
|
const depImage: Image = {
|
|
appId: 1,
|
|
dependent: 0,
|
|
imageId: 2,
|
|
releaseId: 1,
|
|
serviceId: 2,
|
|
name: 'dep-image',
|
|
serviceName: 'dep',
|
|
};
|
|
|
|
const availableImages = [mainImage, depImage];
|
|
const contextWithImages = { ...defaultContext, ...{ availableImages } };
|
|
|
|
try {
|
|
let current = createApp(
|
|
[
|
|
await createService({ running: false }, 1, 'dep', 1, 2, 2, {
|
|
status: 'Installing',
|
|
containerId: 'id',
|
|
}),
|
|
],
|
|
[defaultNetwork],
|
|
[],
|
|
false,
|
|
);
|
|
const target = createApp(
|
|
[
|
|
await createService({}, 1, 'main', 1, 1, 1, { dependsOn: ['dep'] }),
|
|
await createService({}, 1, 'dep', 1, 2, 2),
|
|
],
|
|
[defaultNetwork],
|
|
[],
|
|
true,
|
|
);
|
|
|
|
let steps = current.nextStepsForAppUpdate(contextWithImages, target);
|
|
withSteps(steps)
|
|
.expectStep('start')
|
|
.forTarget((t) => t.serviceName === 'dep').to.exist;
|
|
|
|
withSteps(steps)
|
|
.expectStep('start')
|
|
.forTarget((t) => t.serviceName === 'main').to.not.exist;
|
|
|
|
// we now make our current state have the 'dep' service as started...
|
|
current = createApp(
|
|
[await createService({}, 1, 'dep', 1, 2, 2, { containerId: 'id' })],
|
|
[defaultNetwork],
|
|
[],
|
|
false,
|
|
);
|
|
|
|
// We keep track of the containers that we've tried to start so that we
|
|
// dont spam start requests if the container hasn't started running
|
|
applicationManager.containerStarted['id'] = true;
|
|
|
|
// we should now see a start for the 'main' service...
|
|
steps = current.nextStepsForAppUpdate(
|
|
{ ...contextWithImages, ...{ containerIds: { dep: 'id' } } },
|
|
target,
|
|
);
|
|
withSteps(steps)
|
|
.expectStep('start')
|
|
.forTarget((t) => t.serviceName === 'main').to.exist;
|
|
} finally {
|
|
delete applicationManager.containerStarted['id'];
|
|
}
|
|
});
|
|
|
|
it('should emit a fetch step when an image has not been downloaded for a service', async () => {
|
|
const current = createApp([], [], [], false);
|
|
const target = createApp(
|
|
[await createService({}, 1, 'main', 1, 1, 1)],
|
|
[],
|
|
[],
|
|
true,
|
|
);
|
|
|
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
withSteps(steps).expectStep('fetch').to.exist;
|
|
});
|
|
|
|
it('should stop a container which has stoppped as its target', async () => {
|
|
const current = createApp(
|
|
[await createService({}, 1, 'main', 1, 1, 1)],
|
|
[],
|
|
[],
|
|
false,
|
|
);
|
|
const target = createApp(
|
|
[await createService({ running: false }, 1, 'main', 1, 1, 1)],
|
|
[],
|
|
[],
|
|
true,
|
|
);
|
|
|
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
withSteps(steps).expectStep('stop');
|
|
});
|
|
|
|
it('should create a start step when all that changes is a running state', async () => {
|
|
const contextWithImages = {
|
|
...defaultContext,
|
|
...{
|
|
availableImages: [
|
|
{
|
|
appId: 1,
|
|
dependent: 0,
|
|
imageId: 1,
|
|
releaseId: 1,
|
|
serviceId: 1,
|
|
name: 'main-image',
|
|
serviceName: 'main',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
const current = createApp(
|
|
[await createService({ running: false }, 1, 'main', 1, 1, 1, {})],
|
|
[defaultNetwork],
|
|
[],
|
|
false,
|
|
);
|
|
const target = createApp(
|
|
[await createService({}, 1, 'main', 1, 1, 1, {})],
|
|
[defaultNetwork],
|
|
[],
|
|
true,
|
|
);
|
|
|
|
// now should see a 'start'
|
|
const steps = current.nextStepsForAppUpdate(contextWithImages, target);
|
|
withSteps(steps)
|
|
.expectStep('start')
|
|
.forTarget((t) => t.serviceName === 'main').to.exist;
|
|
});
|
|
|
|
it('should not infer a fetch step when the download is already in progress', async () => {
|
|
const contextWithDownloading = {
|
|
...defaultContext,
|
|
...{
|
|
downloading: [1],
|
|
},
|
|
};
|
|
const current = createApp([], [], [], false);
|
|
const target = createApp(
|
|
[await createService({}, 1, 'main', 1, 1, 1)],
|
|
[],
|
|
[],
|
|
true,
|
|
);
|
|
|
|
const steps = current.nextStepsForAppUpdate(contextWithDownloading, target);
|
|
withSteps(steps).expectStep('fetch').forTarget('main').to.not.exist;
|
|
});
|
|
|
|
it('should create a kill step when a service has to be updated but the strategy is kill-then-download', async () => {
|
|
const contextWithImages = {
|
|
...defaultContext,
|
|
...{
|
|
availableImages: [
|
|
{
|
|
appId: 1,
|
|
dependent: 0,
|
|
imageId: 1,
|
|
releaseId: 1,
|
|
serviceId: 1,
|
|
name: 'main-image',
|
|
serviceName: 'main',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const labels = {
|
|
'io.balena.update.strategy': 'kill-then-download',
|
|
};
|
|
|
|
const current = createApp(
|
|
[
|
|
await createService(
|
|
{ labels, image: 'main-image' },
|
|
1,
|
|
'main',
|
|
1,
|
|
1,
|
|
1,
|
|
{},
|
|
),
|
|
],
|
|
[defaultNetwork],
|
|
[],
|
|
false,
|
|
);
|
|
const target = createApp(
|
|
[
|
|
await createService(
|
|
{ labels, image: 'main-image-2' },
|
|
1,
|
|
'main',
|
|
2,
|
|
1,
|
|
2,
|
|
{},
|
|
),
|
|
],
|
|
[defaultNetwork],
|
|
[],
|
|
true,
|
|
);
|
|
|
|
let steps = current.nextStepsForAppUpdate(contextWithImages, target);
|
|
withSteps(steps).expectStep('kill').forCurrent('main').to.exist;
|
|
|
|
// next volatile state...
|
|
const afterKill = createApp([], [defaultNetwork], [], false);
|
|
|
|
steps = afterKill.nextStepsForAppUpdate(contextWithImages, target);
|
|
|
|
withSteps(steps).expectStep('fetch').to.exist;
|
|
|
|
const fetchStep = withSteps(steps).expectStep('fetch').asStep;
|
|
|
|
expect(fetchStep)
|
|
.to.have.property('image')
|
|
.that.has.property('name')
|
|
.that.equals('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: [4],
|
|
availableImages: [
|
|
{
|
|
appId: 1,
|
|
releaseId: 1,
|
|
dependent: 0,
|
|
name: 'main-image',
|
|
imageId: 1,
|
|
serviceName: 'main',
|
|
serviceId: 1,
|
|
},
|
|
{
|
|
appId: 1,
|
|
releaseId: 1,
|
|
dependent: 0,
|
|
name: 'dep-image',
|
|
imageId: 2,
|
|
serviceName: 'dep',
|
|
serviceId: 2,
|
|
},
|
|
{
|
|
appId: 1,
|
|
releaseId: 2,
|
|
dependent: 0,
|
|
name: 'main-image-2',
|
|
imageId: 3,
|
|
serviceName: 'main',
|
|
serviceId: 1,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const current = createApp(
|
|
[
|
|
await createService({ image: 'main-image' }, 1, 'main', 1, 1, 1, {
|
|
dependsOn: ['dep'],
|
|
}),
|
|
await createService({ image: 'dep-image' }, 1, 'dep', 1, 2, 2, {}),
|
|
],
|
|
[defaultNetwork],
|
|
[],
|
|
false,
|
|
);
|
|
const target = createApp(
|
|
[
|
|
await createService({ image: 'main-image-2' }, 1, 'main', 2, 1, 3, {
|
|
dependsOn: ['dep'],
|
|
}),
|
|
await createService({ image: 'dep-image-2' }, 1, 'dep', 2, 2, 4, {}),
|
|
],
|
|
[defaultNetwork],
|
|
[],
|
|
true,
|
|
);
|
|
|
|
const steps = current.nextStepsForAppUpdate(contextWithImages, target);
|
|
withSteps(steps).expectStep('kill').forCurrent('main').to.not.exist;
|
|
});
|
|
|
|
it('should create several kill steps as long as there is no unmet dependencies', async () => {
|
|
const contextWithImages = {
|
|
...defaultContext,
|
|
...{
|
|
availableImages: [
|
|
{
|
|
appId: 1,
|
|
releaseId: 1,
|
|
dependent: 0,
|
|
name: 'main-image',
|
|
imageId: 1,
|
|
serviceName: 'main',
|
|
serviceId: 1,
|
|
},
|
|
{
|
|
appId: 1,
|
|
releaseId: 2,
|
|
dependent: 0,
|
|
name: 'main-image-2',
|
|
imageId: 2,
|
|
serviceName: 'main',
|
|
serviceId: 1,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const current = createApp(
|
|
[await createService({ image: 'main-image' }, 1, 'main', 1, 1, 1, {})],
|
|
[defaultNetwork],
|
|
[],
|
|
false,
|
|
);
|
|
const target = createApp(
|
|
[await createService({ image: 'main-image-2' }, 1, 'main', 2, 1, 2)],
|
|
[defaultNetwork],
|
|
[],
|
|
true,
|
|
);
|
|
|
|
let steps = current.nextStepsForAppUpdate(contextWithImages, target);
|
|
withSteps(steps).expectStep('kill').forCurrent('main').to.exist;
|
|
|
|
// same states again...
|
|
steps = current.nextStepsForAppUpdate(contextWithImages, target);
|
|
withSteps(steps).expectStep('kill').forCurrent('main').to.exist;
|
|
});
|
|
|
|
it('should create a kill step when a service has to be updated but the strategy is kill-then-download', async () => {
|
|
const labels = {
|
|
'io.balena.update.strategy': 'kill-then-download',
|
|
};
|
|
|
|
const current = createApp([await createService({ labels })], [], [], false);
|
|
const target = createApp(
|
|
[await createService({ privileged: true })],
|
|
[],
|
|
[],
|
|
true,
|
|
);
|
|
|
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
withSteps(steps).expectStep('kill').forCurrent('main');
|
|
});
|
|
|
|
it('should not infer a kill step with the default strategy if a dependency is not downloaded', async () => {
|
|
const current = createApp(
|
|
[await createService({ image: 'image1' })],
|
|
[],
|
|
[],
|
|
false,
|
|
);
|
|
const target = createApp(
|
|
[await createService({ image: 'image2' })],
|
|
[],
|
|
[],
|
|
true,
|
|
);
|
|
|
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
withSteps(steps).expectStep('fetch');
|
|
withSteps(steps).rejectStep('kill');
|
|
});
|
|
|
|
it('should create several kill steps as long as there is no unmet dependencies', async () => {
|
|
const current = createApp(
|
|
[
|
|
await createService({}, 1, 'one', 1, 2),
|
|
await createService({}, 1, 'two', 1, 3),
|
|
await createService({}, 1, 'three', 1, 4),
|
|
],
|
|
[],
|
|
[],
|
|
false,
|
|
);
|
|
const target = createApp(
|
|
[await createService({}, 1, 'three', 1, 4)],
|
|
[],
|
|
[],
|
|
true,
|
|
);
|
|
|
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
withSteps(steps).expectStep('kill').to.have.length(2);
|
|
});
|
|
|
|
it('should not create a service when a network it depends on is not ready', async () => {
|
|
const current = createApp([], [defaultNetwork], [], false);
|
|
const target = createApp(
|
|
[await createService({ networks: ['test'] }, 1)],
|
|
[defaultNetwork, Network.fromComposeObject('test', 1, {})],
|
|
[],
|
|
true,
|
|
);
|
|
|
|
const steps = current.nextStepsForAppUpdate(defaultContext, target);
|
|
withSteps(steps).expectStep('createNetwork');
|
|
withSteps(steps).rejectStep('start');
|
|
}); // no create service, is create network
|
|
|
|
describe('Intermediate state apply support', () => {
|
|
beforeEach(() => {
|
|
appMock.mockSupervisorNetwork(true);
|
|
appMock.mockManagers([], [], []);
|
|
appMock.mockImages([], false, []);
|
|
|
|
applicationManager.setIsApplyingIntermediate(true);
|
|
});
|
|
afterEach(() => {
|
|
appMock.unmockAll();
|
|
applicationManager.setIsApplyingIntermediate(false);
|
|
});
|
|
|
|
it('should generate the correct step sequence for a volume purge request', async () => {
|
|
const service = await createService({
|
|
volumes: ['db-volume'],
|
|
image: 'test-image',
|
|
});
|
|
const volume = Volume.fromComposeObject('db-volume', service.appId, {});
|
|
const contextWithImages = {
|
|
...defaultContext,
|
|
...{
|
|
availableImages: [
|
|
{
|
|
appId: service.appId,
|
|
dependent: 0,
|
|
imageId: service.imageId,
|
|
releaseId: service.releaseId,
|
|
serviceId: service.serviceId,
|
|
name: 'test-image',
|
|
serviceName: service.serviceName,
|
|
} as Image,
|
|
],
|
|
},
|
|
};
|
|
// Temporarily set target services & volumes to empty, as in doPurge
|
|
let intermediateTarget = createApp([], [defaultNetwork], [], true);
|
|
|
|
// Generate current with one service & one volume
|
|
const current = createApp([service], [defaultNetwork], [volume], false);
|
|
|
|
// Step 1: kill
|
|
let steps = current.nextStepsForAppUpdate(
|
|
contextWithImages,
|
|
intermediateTarget,
|
|
);
|
|
withSteps(steps)
|
|
.expectStep('kill')
|
|
.forCurrent(service.serviceName as ServicePredicate);
|
|
expect(steps).to.have.length(1);
|
|
|
|
// Step 2: noop (service is stopping)
|
|
service.status = 'Stopping';
|
|
steps = current.nextStepsForAppUpdate(
|
|
contextWithImages,
|
|
intermediateTarget,
|
|
);
|
|
expectStep('noop', steps);
|
|
expect(steps).to.have.length(1);
|
|
|
|
// No steps, simulate container removal & explicit volume removal as in doPurge
|
|
current.services = [];
|
|
steps = current.nextStepsForAppUpdate(
|
|
contextWithImages,
|
|
intermediateTarget,
|
|
);
|
|
expect(steps).to.have.length(0);
|
|
current.volumes = {};
|
|
|
|
// Step 3: start & createVolume
|
|
service.status = 'Running';
|
|
intermediateTarget = createApp(
|
|
[service],
|
|
[defaultNetwork],
|
|
[volume],
|
|
true,
|
|
);
|
|
steps = current.nextStepsForAppUpdate(
|
|
contextWithImages,
|
|
intermediateTarget,
|
|
);
|
|
expect(steps).to.have.length(2);
|
|
withSteps(steps)
|
|
.expectStep('start')
|
|
.forTarget(service.serviceName as ServicePredicate);
|
|
expectStep('createVolume', steps);
|
|
});
|
|
});
|
|
});
|