balena-supervisor/test/05-device-state.spec.ts
2020-04-08 12:15:06 +01:00

390 lines
10 KiB
TypeScript

import * as Bluebird from 'bluebird';
import * as _ from 'lodash';
import { stub } from 'sinon';
import chai = require('./lib/chai-config');
import prepare = require('./lib/prepare');
// tslint:disable-next-line
chai.use(require('chai-events'));
const { expect } = chai;
import Config from '../src/config';
import { RPiConfigBackend } from '../src/config/backend';
import DB from '../src/db';
import DeviceState from '../src/device-state';
import { loadTargetFromFile } from '../src/device-state/preload';
import Service from '../src/compose/service';
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 testTarget1 = {
local: {
name: 'aDevice',
config: {
HOST_CONFIG_gpu_mem: '256',
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: 'abcdef',
releaseId: 1,
services: [
{
appId: 1234,
serviceId: 23,
imageId: 12345,
serviceName: 'someservice',
releaseId: 1,
image: 'registry2.resin.io/superapp/abcdef:latest',
labels: {
'io.resin.something': 'bar',
},
},
],
volumes: {},
networks: {},
},
},
},
dependent: { apps: [], devices: [] },
};
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',
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: {},
},
},
},
dependent: { apps: [], devices: [] },
};
const testTargetInvalid = {
local: {
name: 'aDeviceWithDifferentName',
config: {
RESIN_HOST_CONFIG_gpu_mem: '512',
},
apps: [
{
appId: '1234',
name: 'superapp',
commit: 'afafafa',
releaseId: '2',
config: {},
services: [
{
serviceId: '23',
serviceName: 'aservice',
imageId: '12345',
image: 'registry2.resin.io/superapp/edfabc',
config: {},
environment: {
' FOO': 'bar',
},
labels: {},
},
{
serviceId: '24',
serviceName: 'anotherService',
imageId: '12346',
image: 'registry2.resin.io/superapp/afaff',
config: {},
environment: {
FOO: 'bro',
},
labels: {},
},
],
},
],
},
dependent: { apps: [], devices: [] },
};
describe('deviceState', () => {
const db = new DB();
const config = new Config({ db });
const logger = {
clearOutOfDateDBLogs() {
/* noop */
},
};
let deviceState: DeviceState;
before(async () => {
prepare();
const eventTracker = {
track: console.log,
};
stub(Service as any, 'extendEnvVars').callsFake(env => {
env['ADDITIONAL_ENV_VAR'] = 'foo';
return env;
});
deviceState = new DeviceState({
db,
config,
eventTracker: eventTracker as any,
logger: logger as any,
apiBinder: null as any,
});
stub(deviceState.applications.docker, 'getNetworkGateway').returns(
Promise.resolve('172.17.0.1'),
);
stub(deviceState.applications.images, 'inspectByName').callsFake(() => {
const err: any = new Error();
err.statusCode = 404;
return Promise.reject(err);
});
(deviceState as any).deviceConfig.configBackend = new RPiConfigBackend();
await db.init();
await config.init();
});
after(() => {
(Service as any).extendEnvVars.restore();
(deviceState.applications.docker
.getNetworkGateway as sinon.SinonStub).restore();
(deviceState.applications.images
.inspectByName as sinon.SinonStub).restore();
});
it('loads a target state from an apps.json file and saves it as target state, then returns it', async () => {
stub(deviceState.applications.images, 'save').returns(Promise.resolve());
stub(deviceState.deviceConfig, 'getCurrent').returns(
Promise.resolve(mockedInitialConfig),
);
try {
await loadTargetFromFile(
process.env.ROOT_MOUNTPOINT + '/apps.json',
deviceState,
);
const targetState = await deviceState.getTarget();
const testTarget = _.cloneDeep(testTarget1);
testTarget.local.apps['1234'].services = _.map(
testTarget.local.apps['1234'].services,
(s: any) => {
s.imageName = s.image;
return Service.fromComposeObject(s, { appName: 'superapp' } as any);
},
) as any;
expect(JSON.parse(JSON.stringify(targetState))).to.deep.equal(
JSON.parse(JSON.stringify(testTarget)),
);
} finally {
(deviceState.applications.images.save as sinon.SinonStub).restore();
(deviceState.deviceConfig.getCurrent as sinon.SinonStub).restore();
}
});
it('stores info for pinning a device after loading an apps.json with a pinDevice field', async () => {
stub(deviceState.applications.images, 'save').returns(Promise.resolve());
stub(deviceState.deviceConfig, 'getCurrent').returns(
Promise.resolve(mockedInitialConfig),
);
await loadTargetFromFile(
process.env.ROOT_MOUNTPOINT + '/apps-pin.json',
deviceState,
);
(deviceState as any).applications.images.save.restore();
(deviceState as any).deviceConfig.getCurrent.restore();
const pinned = await config.get('pinDevice');
expect(pinned)
.to.have.property('app')
.that.equals(1234);
expect(pinned)
.to.have.property('commit')
.that.equals('abcdef');
});
it('emits a change event when a new state is reported', () => {
deviceState.reportCurrentState({ someStateDiff: 'someValue' } as any);
return (expect as any)(deviceState).to.emit('change');
});
it('returns the current state');
it('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 = await (deviceState.applications
.images as any).normalise(service.image);
service.image = imageName;
(service as any).imageName = imageName;
services.push(
Service.fromComposeObject(service, { appName: 'supertest' } as any),
);
}
(testTarget as any).local.apps['1234'].services = services;
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 => {
stub(deviceState as any, 'applyTarget').returns(Promise.resolve());
deviceState.triggerApplyTarget({ force: true });
expect((deviceState as any).applyTarget).to.not.be.called;
setTimeout(() => {
expect((deviceState as any).applyTarget).to.be.calledWith({
force: true,
initial: false,
});
(deviceState as any).applyTarget.restore();
done();
}, 5);
});
it('cancels current promise applying the target state', done => {
(deviceState as any).scheduledApply = { force: false, delay: 100 };
(deviceState as any).applyInProgress = true;
(deviceState as any).applyCancelled = false;
new Bluebird((resolve, reject) => {
setTimeout(resolve, 100000);
(deviceState as any).cancelDelay = reject;
})
.catch(() => {
(deviceState as any).applyCancelled = true;
})
.finally(() => {
expect((deviceState as any).scheduledApply).to.deep.equal({
force: true,
delay: 0,
});
expect((deviceState as any).applyCancelled).to.be.true;
done();
});
deviceState.triggerApplyTarget({ force: true, isFromApi: true });
});
it('applies the target state for device config');
it('applies the target state for applications');
});