balena-supervisor/test/05-device-state.spec.ts
Felipe Lalanne 9c6e5ee11f Remove apps.json after initial preload
This avoids the supervisor trying to get back to the preloaded target
state if the database is deleted by any reason. It does this by moving the
used apps.json to a backup location.

Change-type: patch
Depends-on: #1841
2021-12-13 20:11:42 +00:00

390 lines
12 KiB
TypeScript

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();
});
});