balena-supervisor/test/05-device-state.spec.ts
Felipe Lalanne f1bd4b8d9b Use tags to track supervised images in docker
The image manager module now uses tags instead of docker IDs as the main
way to identify docker images on the engine. That is, if the target
state image has a name `imageName:tag@digest`, the supervisor will always use
the given `imageName` and `tag` (which may be empty) to tag the image on
the engine after fetching. This PR also adds checkups to ensure
consistency is maintained between the database and the engine.

Using tags allows to simplify query and removal operations, since now
removing the image now means removing tags matching the image name.

Before this change the supervisor relied only on information in the
supervisor database, and used that to remove images by docker ID. However, the docker
id is not a reliable identifier, since images retain the same id between
releases or between services in the same release.

List of squashed commits
- Remove custom type NormalizedImageInfo
- Remove dependency on docker-toolbelt
- Use tags to traack supervised images in docker
- Ensure tag removal occurs in sequence
- Only save database image after download confirmed

Relates-to: #1616 #1579
Change-type: patch
2021-07-26 09:52:25 -04:00

376 lines
11 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 } 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';
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 () => {
await loadTargetFromFile(process.env.ROOT_MOUNTPOINT + '/apps.json');
const targetState = await deviceState.getTarget();
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');
});
it('stores info for pinning a device after loading an apps.json with a pinDevice field', async () => {
await loadTargetFromFile(process.env.ROOT_MOUNTPOINT + '/apps-pin.json');
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', (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 = await 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();
});
});