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, appId = 1, serviceName = 'test', releaseId = 2, serviceId = 3, imageId = 4, extraState?: Partial, ) { 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) => 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 = 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 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 });