balena-supervisor/test/14-application-manager.spec.ts
Cameron Diver b31d5007fb Move database app processing out to its own module
This is part of the work to make the application-manager module much
less monolithic, in preperation for system apps and more generally
multi-app.

Change-type: patch
Signed-off-by: Cameron Diver <cameron@balena.io>
2020-06-11 11:55:11 +01:00

626 lines
15 KiB
TypeScript

import * as Bluebird from 'bluebird';
import * as _ from 'lodash';
import { stub } from 'sinon';
import Network from '../src/compose/network';
import Service from '../src/compose/service';
import Volume from '../src/compose/volume';
import DeviceState from '../src/device-state';
import * as dockerUtils from '../src/lib/docker-utils';
import * as images from '../src/compose/images';
import chai = require('./lib/chai-config');
import prepare = require('./lib/prepare');
import * as db from '../src/db';
import * as dbFormat from '../src/device-state/db-format';
import * as targetStateCache from '../src/device-state/target-state-cache';
import * as config from '../src/config';
import { TargetApplication, TargetApplications } from '../src/types/state';
// tslint:disable-next-line
chai.use(require('chai-events'));
const { expect } = chai;
let availableImages: any[] | null;
let targetState: any[] | null;
const dependentStateFormat = {
appId: 1234,
image: 'foo/bar',
commit: 'bar',
releaseId: 3,
name: 'app',
config: { RESIN_FOO: 'var' },
environment: { FOO: 'var2' },
parentApp: 256,
imageId: 45,
};
const dependentStateFormatNormalised = {
appId: 1234,
image: 'foo/bar:latest',
commit: 'bar',
releaseId: 3,
name: 'app',
config: { RESIN_FOO: 'var' },
environment: { FOO: 'var2' },
parentApp: 256,
imageId: 45,
};
let currentState = (targetState = availableImages = null);
const dependentDBFormat = {
appId: 1234,
image: 'foo/bar:latest',
commit: 'bar',
releaseId: 3,
name: 'app',
config: JSON.stringify({ RESIN_FOO: 'var' }),
environment: JSON.stringify({ FOO: 'var2' }),
parentApp: 256,
imageId: 45,
};
describe('ApplicationManager', function () {
const originalInspectByName = images.inspectByName;
before(async function () {
await prepare();
this.deviceState = new DeviceState({
apiBinder: null as any,
});
this.applications = this.deviceState.applications;
// @ts-expect-error assigning to a RO property
images.inspectByName = () =>
Promise.resolve({
Config: {
Cmd: ['someCommand'],
Entrypoint: ['theEntrypoint'],
Env: [],
Labels: {},
Volumes: [],
},
});
stub(dockerUtils, 'getNetworkGateway').returns(
Bluebird.Promise.resolve('172.17.0.1'),
);
stub(dockerUtils.docker, 'listContainers').returns(
Bluebird.Promise.resolve([]),
);
stub(dockerUtils.docker, 'listImages').returns(
Bluebird.Promise.resolve([]),
);
stub(Service as any, 'extendEnvVars').callsFake(function (env) {
env['ADDITIONAL_ENV_VAR'] = 'foo';
return env;
});
this.normaliseCurrent = async function (current: {
local: { apps: Dictionary<TargetApplication> };
}) {
const currentCloned: any = _.cloneDeep(current);
currentCloned.local.apps = {};
_.each(current.local.apps, (app, appId) => {
const appCloned = {
...app,
services: _.mapValues(app.services, (svc) =>
// @ts-ignore
Service.fromComposeObject(svc, { appName: 'test' }),
),
networks: _.mapValues(app.networks, (conf, name) =>
Network.fromComposeObject(name, parseInt(appId, 10), conf),
),
volumes: _.mapValues(app.volumes, (conf, name) =>
Volume.fromComposeObject(name, parseInt(appId, 10), conf),
),
};
currentCloned.local.apps[parseInt(appId, 10)] = appCloned;
});
return currentCloned;
};
this.normaliseTarget = async (
target: {
local: { apps: TargetApplications };
},
available: any,
) => {
const source = await config.get('apiEndpoint');
const cloned: any = _.cloneDeep(target);
// @ts-ignore types don't quite match up
await dbFormat.setApps(target.local.apps, source);
cloned.local.apps = await dbFormat.getApps();
// We mock what createTargetService does when an image is available
_.each(cloned.local.apps, (app) => {
_.each(app.services, (svc) => {
const img = _.find(available, (i) => i.name === svc.config.image);
if (img != null) {
svc.config.image = img.dockerImageId;
}
});
});
return cloned;
};
});
beforeEach(async () => {
({
currentState,
targetState,
availableImages,
} = require('./lib/application-manager-test-states'));
await db.models('app').del();
// @ts-expect-error modification of a RO property
targetStateCache.targetState = undefined;
});
after(async function () {
// @ts-expect-error Assigning to a RO property
images.inspectByName = originalInspectByName;
// @ts-expect-error restore on non-stubbed type
dockerUtils.getNetworkGateway.restore();
// @ts-expect-error restore on non-stubbed type
dockerUtils.docker.listContainers.restore();
// @ts-expect-error restore on non-stubbed type
dockerUtils.docker.listImages.restore();
// @ts-expect-error use of private function
Service.extendEnvVars.restore();
await db.models('app').del();
// @ts-expect-error modification of a RO property
targetStateCache.targetState = undefined;
});
it('should init', function () {
return this.applications.init();
});
it('infers a start step when all that changes is a running state', function () {
return Bluebird.Promise.join(
// @ts-ignore
this.normaliseCurrent(currentState[0]),
// @ts-ignore
this.normaliseTarget(targetState[0], availableImages[0]),
(current, target) => {
const steps = this.applications._inferNextSteps(
false,
// @ts-ignore
availableImages[0],
[],
true,
current,
target,
false,
{},
{},
);
return expect(steps).to.eventually.deep.equal([
{
action: 'start',
current: current.local.apps['1234'].services[24],
target: target.local.apps['1234'].services[24],
serviceId: 24,
appId: 1234,
options: {},
},
]);
},
);
});
it('infers a kill step when a service has to be removed', function () {
return Bluebird.Promise.join(
// @ts-ignore
this.normaliseCurrent(currentState[0]),
// @ts-ignore
this.normaliseTarget(targetState[1], availableImages[0]),
(current, target) => {
const steps = this.applications._inferNextSteps(
false,
// @ts-ignore
availableImages[0],
[],
true,
current,
target,
false,
{},
{},
);
return expect(steps).to.eventually.deep.equal([
{
action: 'kill',
current: current.local.apps['1234'].services[24],
target: undefined,
serviceId: 24,
appId: 1234,
options: {},
},
]);
},
);
});
it('infers a fetch step when a service has to be updated', function () {
return Bluebird.Promise.join(
// @ts-ignore
this.normaliseCurrent(currentState[0]),
// @ts-ignore
this.normaliseTarget(targetState[2], availableImages[0]),
(current, target) => {
const steps = this.applications._inferNextSteps(
false,
// @ts-ignore
availableImages[0],
[],
true,
current,
target,
false,
{},
{},
);
return expect(steps).to.eventually.deep.equal([
{
action: 'fetch',
image: this.applications.imageForService(
target.local.apps['1234'].services[24],
),
serviceId: 24,
appId: 1234,
serviceName: 'anotherService',
},
]);
},
);
});
it('does not infer a fetch step when the download is already in progress', function () {
return Bluebird.Promise.join(
// @ts-ignore
this.normaliseCurrent(currentState[0]),
// @ts-ignore
this.normaliseTarget(targetState[2], availableImages[0]),
(current, target) => {
const steps = this.applications._inferNextSteps(
false,
// @ts-ignore
availableImages[0],
[target.local.apps['1234'].services[24].imageId],
true,
current,
target,
false,
{},
{},
);
return expect(steps).to.eventually.deep.equal([
{ action: 'noop', appId: 1234 },
]);
},
);
});
it('infers a kill step when a service has to be updated but the strategy is kill-then-download', function () {
return Bluebird.Promise.join(
// @ts-ignore
this.normaliseCurrent(currentState[0]),
// @ts-ignore
this.normaliseTarget(targetState[3], availableImages[0]),
(current, target) => {
const steps = this.applications._inferNextSteps(
false,
// @ts-ignore
availableImages[0],
[],
true,
current,
target,
false,
{},
{},
);
return expect(steps).to.eventually.deep.equal([
{
action: 'kill',
current: current.local.apps['1234'].services[24],
target: target.local.apps['1234'].services[24],
serviceId: 24,
appId: 1234,
options: {},
},
]);
},
);
});
it('does not infer to kill a service with default strategy if a dependency is not downloaded', function () {
return Bluebird.Promise.join(
// @ts-ignore
this.normaliseCurrent(currentState[4]),
// @ts-ignore
this.normaliseTarget(targetState[4], availableImages[2]),
(current, target) => {
const steps = this.applications._inferNextSteps(
false,
// @ts-ignore
availableImages[2],
[],
true,
current,
target,
false,
{},
{},
);
return expect(steps).to.eventually.have.deep.members([
{
action: 'fetch',
image: this.applications.imageForService(
target.local.apps['1234'].services[23],
),
serviceId: 23,
appId: 1234,
serviceName: 'aservice',
},
{ action: 'noop', appId: 1234 },
]);
},
);
});
it('infers to kill several services as long as there is no unmet dependency', function () {
return Bluebird.Promise.join(
// @ts-ignore
this.normaliseCurrent(currentState[0]),
// @ts-ignore
this.normaliseTarget(targetState[5], availableImages[1]),
(current, target) => {
const steps = this.applications._inferNextSteps(
false,
// @ts-ignore
availableImages[1],
[],
true,
current,
target,
false,
{},
{},
);
return expect(steps).to.eventually.have.deep.members([
{
action: 'kill',
current: current.local.apps['1234'].services[23],
target: target.local.apps['1234'].services[23],
serviceId: 23,
appId: 1234,
options: {},
},
{
action: 'kill',
current: current.local.apps['1234'].services[24],
target: target.local.apps['1234'].services[24],
serviceId: 24,
appId: 1234,
options: {},
},
]);
},
);
});
it('infers to start the dependency first', function () {
return Bluebird.Promise.join(
// @ts-ignore
this.normaliseCurrent(currentState[1]),
// @ts-ignore
this.normaliseTarget(targetState[4], availableImages[1]),
(current, target) => {
const steps = this.applications._inferNextSteps(
false,
// @ts-ignore
availableImages[1],
[],
true,
current,
target,
false,
{},
{},
);
return expect(steps).to.eventually.have.deep.members([
{
action: 'start',
current: null,
target: target.local.apps['1234'].services[23],
serviceId: 23,
appId: 1234,
options: {},
},
]);
},
);
});
it('infers to start a service once its dependency has been met', function () {
return Bluebird.Promise.join(
// @ts-ignore
this.normaliseCurrent(currentState[2]),
// @ts-ignore
this.normaliseTarget(targetState[4], availableImages[1]),
(current, target) => {
const steps = this.applications._inferNextSteps(
false,
// @ts-ignore
availableImages[1],
[],
true,
current,
target,
false,
{},
{},
{},
);
return expect(steps).to.eventually.have.deep.members([
{
action: 'start',
current: null,
target: target.local.apps['1234'].services[24],
serviceId: 24,
appId: 1234,
options: {},
},
]);
},
);
});
it('infers to remove spurious containers', function () {
return Bluebird.Promise.join(
// @ts-ignore
this.normaliseCurrent(currentState[3]),
// @ts-ignore
this.normaliseTarget(targetState[4], availableImages[1]),
(current, target) => {
const steps = this.applications._inferNextSteps(
false,
// @ts-ignore
availableImages[1],
[],
true,
current,
target,
false,
{},
{},
);
return expect(steps).to.eventually.have.deep.members([
{
action: 'kill',
current: current.local.apps['1234'].services[23],
target: undefined,
serviceId: 23,
appId: 1234,
options: {},
},
{
action: 'start',
current: null,
target: target.local.apps['1234'].services[24],
serviceId: 24,
appId: 1234,
options: {},
},
]);
},
);
});
it('converts a dependent app from a state format to a db format, normalising the image name', function () {
const app = this.applications.proxyvisor.normaliseDependentAppForDB(
dependentStateFormat,
);
return expect(app).to.eventually.deep.equal(dependentDBFormat);
});
it('converts a dependent app in DB format into state format', function () {
const app = this.applications.proxyvisor.normaliseDependentAppFromDB(
dependentDBFormat,
);
return expect(app).to.eventually.deep.equal(dependentStateFormatNormalised);
});
return describe('Volumes', function () {
before(function () {
return stub(this.applications, 'removeAllVolumesForApp').returns(
Bluebird.Promise.resolve([
{
action: 'removeVolume',
current: Volume.fromComposeObject('my_volume', 12, {}),
},
]),
);
});
after(function () {
return this.applications.removeAllVolumesForApp.restore();
});
it('should not remove volumes when they are no longer referenced', function () {
return Bluebird.Promise.join(
// @ts-ignore
this.normaliseCurrent(currentState[6]),
// @ts-ignore
this.normaliseTarget(targetState[0], availableImages[0]),
(current, target) => {
return this.applications
._inferNextSteps(
false,
// @ts-ignore
availableImages[0],
[],
true,
current,
target,
false,
{},
{},
)
.then(
// @ts-ignore
(steps) =>
expect(
_.every(steps, (s) => s.action !== 'removeVolume'),
'Volumes from current app should not be removed',
).to.be.true,
);
},
);
});
it('should remove volumes from previous applications', function () {
return Bluebird.Promise.join(
// @ts-ignore
this.normaliseCurrent(currentState[5]),
// @ts-ignore
this.normaliseTarget(targetState[6], []),
(current, target) => {
return (
this.applications
._inferNextSteps(
false,
[],
[],
true,
current,
target,
false,
{},
{},
)
// tslint:disable-next-line
.then(function (steps: { current: any }[]) {
expect(steps).to.have.length(1);
expect(steps[0])
.to.have.property('action')
.that.equals('removeVolume');
return expect(steps[0].current)
.to.have.property('appId')
.that.equals(12);
})
);
},
);
});
});
});