import * as _ from 'lodash'; import { SinonStub, stub } from 'sinon'; import { expect } from 'chai'; import { StatusCodeError, UpdatesLockedError } from '../src/lib/errors'; import prepare = require('./lib/prepare'); import * as dockerUtils from '../src/lib/docker-utils'; import * as config from '../src/config'; import * as images from '../src/compose/images'; import { ConfigTxt } from '../src/config/backends/config-txt'; import * as deviceState from '../src/device-state'; import * as deviceConfig from '../src/device-config'; import { loadTargetFromFile, appsJsonBackup, } from '../src/device-state/preload'; import Service from '../src/compose/service'; import { intialiseContractRequirements } from '../src/lib/contracts'; import * as updateLock from '../src/lib/update-lock'; import * as fsUtils from '../src/lib/fs-utils'; const mockedInitialConfig = { RESIN_SUPERVISOR_CONNECTIVITY_CHECK: 'true', RESIN_SUPERVISOR_DELTA: 'false', RESIN_SUPERVISOR_DELTA_APPLY_TIMEOUT: '0', RESIN_SUPERVISOR_DELTA_REQUEST_TIMEOUT: '30000', RESIN_SUPERVISOR_DELTA_RETRY_COUNT: '30', RESIN_SUPERVISOR_DELTA_RETRY_INTERVAL: '10000', RESIN_SUPERVISOR_DELTA_VERSION: '2', RESIN_SUPERVISOR_INSTANT_UPDATE_TRIGGER: 'true', RESIN_SUPERVISOR_LOCAL_MODE: 'false', RESIN_SUPERVISOR_LOG_CONTROL: 'true', RESIN_SUPERVISOR_OVERRIDE_LOCK: 'false', RESIN_SUPERVISOR_POLL_INTERVAL: '60000', RESIN_SUPERVISOR_VPN_CONTROL: 'true', }; const testTarget2 = { local: { name: 'aDeviceWithDifferentName', config: { RESIN_HOST_CONFIG_gpu_mem: '512', }, apps: { '1234': { name: 'superapp', commit: 'afafafa', releaseId: 2, services: { '23': { serviceName: 'aservice', imageId: 12345, image: 'registry2.resin.io/superapp/edfabc', environment: { FOO: 'bar', }, labels: {}, }, '24': { serviceName: 'anotherService', imageId: 12346, image: 'registry2.resin.io/superapp/afaff', environment: { FOO: 'bro', }, labels: {}, }, }, volumes: {}, networks: {}, }, }, }, dependent: { apps: {}, devices: {} }, }; const testTargetWithDefaults2 = { local: { name: 'aDeviceWithDifferentName', config: { HOST_CONFIG_gpu_mem: '512', HOST_FIREWALL_MODE: 'off', HOST_DISCOVERABILITY: 'true', SUPERVISOR_CONNECTIVITY_CHECK: 'true', SUPERVISOR_DELTA: 'false', SUPERVISOR_DELTA_APPLY_TIMEOUT: '0', SUPERVISOR_DELTA_REQUEST_TIMEOUT: '30000', SUPERVISOR_DELTA_RETRY_COUNT: '30', SUPERVISOR_DELTA_RETRY_INTERVAL: '10000', SUPERVISOR_DELTA_VERSION: '2', SUPERVISOR_INSTANT_UPDATE_TRIGGER: 'true', SUPERVISOR_LOCAL_MODE: 'false', SUPERVISOR_LOG_CONTROL: 'true', SUPERVISOR_OVERRIDE_LOCK: 'false', SUPERVISOR_POLL_INTERVAL: '60000', SUPERVISOR_VPN_CONTROL: 'true', SUPERVISOR_PERSISTENT_LOGGING: 'false', }, apps: { '1234': { appId: 1234, name: 'superapp', commit: 'afafafa', releaseId: 2, services: [ _.merge( { appId: 1234, serviceId: 23, releaseId: 2 }, _.clone(testTarget2.local.apps['1234'].services['23']), ), _.merge( { appId: 1234, serviceId: 24, releaseId: 2 }, _.clone(testTarget2.local.apps['1234'].services['24']), ), ], volumes: {}, networks: {}, }, }, }, }; const testTargetInvalid = { 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', config: {}, environment: { ' FOO': 'bar', }, labels: {}, }, 24: { serviceId: '24', serviceName: 'anotherService', imageId: '12346', image: 'registry2.resin.io/superapp/afaff', config: {}, environment: { FOO: 'bro', }, labels: {}, }, }, }, }, }, dependent: { apps: {}, devices: {} }, }; describe('deviceState', () => { let source: string; const originalImagesSave = images.save; const originalImagesInspect = images.inspectByName; const originalGetCurrent = deviceConfig.getCurrent; before(async () => { await prepare(); await config.initialized; await deviceState.initialized; source = await config.get('apiEndpoint'); stub(Service as any, 'extendEnvVars').callsFake((env) => { env['ADDITIONAL_ENV_VAR'] = 'foo'; return env; }); intialiseContractRequirements({ supervisorVersion: '11.0.0', deviceType: 'intel-nuc', }); stub(dockerUtils, 'getNetworkGateway').returns( Promise.resolve('172.17.0.1'), ); // @ts-expect-error Assigning to a RO property images.cleanImageData = () => { console.log('Cleanup database called'); }; // @ts-expect-error Assigning to a RO property images.save = () => Promise.resolve(); // @ts-expect-error Assigning to a RO property images.inspectByName = () => { const err: StatusCodeError = new Error(); err.statusCode = 404; return Promise.reject(err); }; // @ts-expect-error Assigning to a RO property deviceConfig.configBackend = new ConfigTxt(); // @ts-expect-error Assigning to a RO property deviceConfig.getCurrent = async () => mockedInitialConfig; }); after(() => { (Service as any).extendEnvVars.restore(); (dockerUtils.getNetworkGateway as sinon.SinonStub).restore(); // @ts-expect-error Assigning to a RO property images.save = originalImagesSave; // @ts-expect-error Assigning to a RO property images.inspectByName = originalImagesInspect; // @ts-expect-error Assigning to a RO property deviceConfig.getCurrent = originalGetCurrent; }); beforeEach(async () => { await prepare(); }); it('loads a target state from an apps.json file and saves it as target state, then returns it', async () => { const appsJson = process.env.ROOT_MOUNTPOINT + '/apps.json'; await loadTargetFromFile(appsJson); const targetState = await deviceState.getTarget(); expect(await fsUtils.exists(appsJsonBackup(appsJson))).to.be.true; 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('registry2.resin.io/superapp/abcdef:latest'); expect(app.services[0].config) .to.have.property('labels') .that.has.property('io.balena.something') .that.equals('bar'); 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('registry2.resin.io/superapp/abcdef:latest'); expect(app.services[0].config) .to.have.property('labels') .that.has.property('io.balena.something') .that.equals('bar'); 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('registry2.resin.io/superapp/abcdef:latest'); expect(app.services[0].config) .to.have.property('labels') .that.has.property('io.balena.something') .that.equals('bar'); 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('registry2.resin.io/superapp/abcdef:latest'); expect(app.services[0].config) .to.have.property('labels') .that.has.property('io.balena.something') .that.equals('bar'); 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('registry2.resin.io/superapp/abcdef:latest'); expect(app.services[0].config) .to.have.property('labels') .that.has.property('io.balena.something') .that.equals('bar'); // Restore renamed apps.json await fsUtils.safeRename(appsJsonBackup(appsJson), appsJson); }); it('stores info for pinning a device after loading an apps.json with a pinDevice field', async () => { const appsJson = process.env.ROOT_MOUNTPOINT + '/apps-pin.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; // Restore renamed apps.json await fsUtils.safeRename(appsJsonBackup(appsJson), appsJson); }); it('emits a change event when a new state is reported', (done) => { deviceState.once('change', done); deviceState.reportCurrentState({ someStateDiff: 'someValue' } as any); }); it.skip('writes the target state to the db with some extra defaults', async () => { const testTarget = _.cloneDeep(testTargetWithDefaults2); const services: Service[] = []; for (const service of testTarget.local.apps['1234'].services) { const imageName = images.normalise(service.image); service.image = imageName; (service as any).imageName = imageName; services.push( await Service.fromComposeObject(service, { appName: 'supertest', } as any), ); } (testTarget as any).local.apps['1234'].services = _.keyBy( services, 'serviceId', ); (testTarget as any).local.apps['1234'].source = source; await deviceState.setTarget(testTarget2); const target = await deviceState.getTarget(); expect(JSON.parse(JSON.stringify(target))).to.deep.equal( JSON.parse(JSON.stringify(testTarget)), ); }); it('does not allow setting an invalid target state', () => { expect(deviceState.setTarget(testTargetInvalid as any)).to.be.rejected; }); it('allows triggering applying the target state', (done) => { const applyTargetStub = stub(deviceState, 'applyTarget').returns( Promise.resolve(), ); deviceState.triggerApplyTarget({ force: true }); expect(applyTargetStub).to.not.be.called; setTimeout(() => { expect(applyTargetStub).to.be.calledWith({ force: true, initial: false, }); applyTargetStub.restore(); done(); }, 1000); }); // 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'; stub(updateLock, 'abortIfHUPInProgress').throws( new UpdatesLockedError(testErrMsg), ); await expect(deviceState.reboot()) .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 SinonStub).restore(); }); });