import { expect } from 'chai'; import * as sinon from 'sinon'; import { UpdatesLockedError } from '~/lib/errors'; import * as fsUtils from '~/lib/fs-utils'; import * as updateLock from '~/lib/update-lock'; import * as config from '~/src/config'; import * as deviceState from '~/src/device-state'; import { appsJsonBackup, loadTargetFromFile } from '~/src/device-state/preload'; import { TargetState } from '~/src/types'; import { promises as fs } from 'fs'; import { initializeContractRequirements } from '~/lib/contracts'; import { testfs } from 'mocha-pod'; import { createDockerImage } from '~/test-lib/docker-helper'; import * as Docker from 'dockerode'; import { setTimeout } from 'timers/promises'; describe('device-state', () => { const docker = new Docker(); before(async () => { await config.initialized(); // Set the device uuid await config.set({ uuid: 'local' }); initializeContractRequirements({ supervisorVersion: '11.0.0', deviceType: 'intel-nuc', }); }); after(async () => { await docker.pruneImages({ filters: { dangling: { false: true } } }); }); it('loads a target state from an apps.json file and saves it as target state, then returns it', async () => { const localFs = await testfs( { '/data/apps.json': testfs.from('test/data/apps.json') }, { cleanup: ['/data/apps.json.preloaded'] }, ).enable(); // The image needs to exist before the test const dockerImageId = await createDockerImage( 'registry2.resin.io/superapp/abcdef:latest', ['io.balena.testing=1'], docker, ); const appsJson = '/data/apps.json'; await expect( fs.access(appsJson), 'apps.json exists before loading the target', ).to.not.be.rejected; await loadTargetFromFile(appsJson); const targetState = await deviceState.getTarget(); await expect( fs.access(appsJsonBackup(appsJson)), 'apps.json.preloaded is created after loading the target', ).to.not.be.rejected; expect(targetState) .to.have.property('local') .that.has.property('config') .that.has.property('HOST_CONFIG_gpu_mem') .that.equals('256'); expect(targetState) .to.have.property('local') .that.has.property('apps') .that.has.property('1234') .that.is.an('object'); const app = targetState.local.apps[1234]; expect(app).to.have.property('appName').that.equals('superapp'); expect(app).to.have.property('services').that.is.an('array').with.length(1); expect(app.services[0]) .to.have.property('config') .that.has.property('image') .that.equals(dockerImageId); expect(app.services[0].config) .to.have.property('labels') .that.has.property('io.balena.something') .that.equals('bar'); // Remove the image await docker.getImage(dockerImageId).remove(); await localFs.restore(); }); it('stores info for pinning a device after loading an apps.json with a pinDevice field', async () => { const localFs = await testfs( { '/data/apps.json': testfs.from('test/data/apps-pin.json') }, { cleanup: ['/data/apps.json.preloaded'] }, ).enable(); // The image needs to exist before the test const dockerImageId = await createDockerImage( 'registry2.resin.io/superapp/abcdef:latest', ['io.balena.testing=1'], docker, ); const appsJson = '/data/apps.json'; await loadTargetFromFile(appsJson); const pinned = await config.get('pinDevice'); expect(pinned).to.have.property('app').that.equals(1234); expect(pinned).to.have.property('commit').that.equals('abcdef'); expect(await fsUtils.exists(appsJsonBackup(appsJson))).to.be.true; // Remove the image await docker.getImage(dockerImageId).remove(); await localFs.restore(); }); it('emits a change event when a new state is reported', (done) => { deviceState.once('change', done); deviceState.reportCurrentState({ someStateDiff: 'someValue' } as any); }); it('writes the target state to the db with some extra defaults', async () => { await deviceState.setTarget({ local: { name: 'aDeviceWithDifferentName', config: { RESIN_HOST_CONFIG_gpu_mem: '512', }, apps: { myapp: { id: 1234, name: 'superapp', class: 'fleet', releases: { afafafa: { id: 2, services: { aservice: { id: 23, image_id: 12345, image: 'registry2.resin.io/superapp/edfabc', environment: { FOO: 'bar', }, labels: {}, }, anotherService: { id: 24, image_id: 12346, image: 'registry2.resin.io/superapp/afaff', environment: { FOO: 'bro', }, labels: {}, }, }, volumes: {}, networks: {}, }, }, }, }, }, } as TargetState); const targetState = await deviceState.getTarget(); expect(targetState) .to.have.property('local') .that.has.property('config') .that.has.property('HOST_CONFIG_gpu_mem') .that.equals('512'); expect(targetState) .to.have.property('local') .that.has.property('apps') .that.has.property('1234') .that.is.an('object'); const app = targetState.local.apps[1234]; expect(app).to.have.property('appName').that.equals('superapp'); expect(app).to.have.property('commit').that.equals('afafafa'); expect(app).to.have.property('services').that.is.an('array').with.length(2); expect(app.services[0]) .to.have.property('config') .that.has.property('image') .that.equals('registry2.resin.io/superapp/edfabc:latest'); expect(app.services[0].config) .to.have.property('environment') .that.has.property('FOO') .that.equals('bar'); expect(app.services[1]) .to.have.property('config') .that.has.property('image') .that.equals('registry2.resin.io/superapp/afaff:latest'); expect(app.services[1].config) .to.have.property('environment') .that.has.property('FOO') .that.equals('bro'); }); it('does not allow setting an invalid target state', () => { // v2 state should be rejected expect( deviceState.setTarget({ local: { name: 'aDeviceWithDifferentName', config: { RESIN_HOST_CONFIG_gpu_mem: '512', }, apps: { 1234: { appId: '1234', name: 'superapp', commit: 'afafafa', releaseId: '2', config: {}, services: { 23: { serviceId: '23', serviceName: 'aservice', imageId: '12345', image: 'registry2.resin.io/superapp/edfabc', environment: { ' FOO': 'bar', }, labels: {}, }, 24: { serviceId: '24', serviceName: 'anotherService', imageId: '12346', image: 'registry2.resin.io/superapp/afaff', environment: { FOO: 'bro', }, labels: {}, }, }, }, }, }, } as any), ).to.be.rejected; }); it('allows triggering applying the target state', async () => { const applyTargetStub = sinon .stub(deviceState, 'applyTarget') .returns(Promise.resolve()); deviceState.triggerApplyTarget({ force: true }); expect(applyTargetStub).to.not.be.called; await setTimeout(1000); expect(applyTargetStub).to.be.calledWith({ force: true, initial: false, }); applyTargetStub.restore(); }); it('accepts a target state with an valid contract', async () => { await deviceState.setTarget({ local: { name: 'aDeviceWithDifferentName', config: {}, apps: { myapp: { id: 1234, name: 'superapp', class: 'fleet', releases: { one: { id: 2, services: { valid: { id: 23, image_id: 12345, image: 'registry2.resin.io/superapp/valid', environment: {}, labels: {}, }, alsoValid: { id: 24, image_id: 12346, image: 'registry2.resin.io/superapp/afaff', contract: { type: 'sw.container', slug: 'supervisor-version', name: 'Enforce supervisor version', requires: [ { type: 'sw.supervisor', version: '>=11.0.0', }, ], }, environment: {}, labels: {}, }, }, volumes: {}, networks: {}, }, }, }, }, }, } as TargetState); const targetState = await deviceState.getTarget(); const app = targetState.local.apps[1234]; expect(app).to.have.property('appName').that.equals('superapp'); expect(app).to.have.property('commit').that.equals('one'); // Only a single service should be on the target state expect(app).to.have.property('services').that.is.an('array').with.length(2); expect(app.services[1]) .that.has.property('serviceName') .that.equals('alsoValid'); }); it('accepts a target state with an invalid contract for an optional container', async () => { await deviceState.setTarget({ local: { name: 'aDeviceWithDifferentName', config: {}, apps: { myapp: { id: 1234, name: 'superapp', class: 'fleet', releases: { one: { id: 2, services: { valid: { id: 23, image_id: 12345, image: 'registry2.resin.io/superapp/valid', environment: {}, labels: {}, }, invalidButOptional: { id: 24, image_id: 12346, image: 'registry2.resin.io/superapp/afaff', contract: { type: 'sw.container', slug: 'supervisor-version', name: 'Enforce supervisor version', requires: [ { type: 'sw.supervisor', version: '>=12.0.0', }, ], }, environment: {}, labels: { 'io.balena.features.optional': 'true', }, }, }, volumes: {}, networks: {}, }, }, }, }, }, } as TargetState); const targetState = await deviceState.getTarget(); const app = targetState.local.apps[1234]; expect(app).to.have.property('appName').that.equals('superapp'); expect(app).to.have.property('commit').that.equals('one'); // Only a single service should be on the target state expect(app).to.have.property('services').that.is.an('array').with.length(1); expect(app.services[0]) .that.has.property('serviceName') .that.equals('valid'); }); it('rejects a target state with invalid contract and non optional service', async () => { await expect( deviceState.setTarget({ local: { name: 'aDeviceWithDifferentName', config: {}, apps: { myapp: { id: 1234, name: 'superapp', class: 'fleet', releases: { one: { id: 2, services: { valid: { id: 23, image_id: 12345, image: 'registry2.resin.io/superapp/valid', environment: {}, labels: {}, }, invalid: { id: 24, image_id: 12346, image: 'registry2.resin.io/superapp/afaff', contract: { type: 'sw.container', slug: 'supervisor-version', name: 'Enforce supervisor version', requires: [ { type: 'sw.supervisor', version: '>=12.0.0', }, ], }, environment: {}, labels: {}, }, }, volumes: {}, networks: {}, }, }, }, }, }, } as TargetState), ).to.be.rejected; }); // TODO: There is no easy way to test this behaviour with the current // interface of device-state. We should really think about the device-state // interface to allow this flexibility (and to avoid having to change module // internal variables) it.skip('cancels current promise applying the target state'); it.skip('applies the target state for device config'); it.skip('applies the target state for applications'); it('prevents reboot or shutdown when HUP rollback breadcrumbs are present', async () => { const testErrMsg = 'Waiting for Host OS updates to finish'; sinon .stub(updateLock, 'abortIfHUPInProgress') .throws(new UpdatesLockedError(testErrMsg)); await expect(deviceState.shutdown({ reboot: true })) .to.eventually.be.rejectedWith(testErrMsg) .and.be.an.instanceOf(UpdatesLockedError); await expect(deviceState.shutdown()) .to.eventually.be.rejectedWith(testErrMsg) .and.be.an.instanceOf(UpdatesLockedError); (updateLock.abortIfHUPInProgress as sinon.SinonStub).restore(); }); });