mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-01-30 16:14:11 +00:00
e9a52e6786
This moves from throwing an error when an app is rejected due to unmet requirements (because of contracts) to storing the target with a `rejected` flag on the database. The application manager filters rejected apps when calculating steps to prevent them from affecting the current state. The state engine uses the rejection info to generate the state report. Change-type: minor
517 lines
14 KiB
TypeScript
517 lines
14 KiB
TypeScript
import { expect } from 'chai';
|
|
import * as sinon from 'sinon';
|
|
import { UpdatesLockedError } from '~/lib/errors';
|
|
import * as fsUtils from '~/lib/fs-utils';
|
|
import * as updateLock from '~/lib/update-lock';
|
|
import * as config from '~/src/config';
|
|
import * as deviceState from '~/src/device-state';
|
|
import { appsJsonBackup, loadTargetFromFile } from '~/src/device-state/preload';
|
|
import type { TargetState } from '~/src/types';
|
|
import { promises as fs } from 'fs';
|
|
import { initializeContractRequirements } from '~/lib/contracts';
|
|
|
|
import { testfs } from 'mocha-pod';
|
|
import { createDockerImage } from '~/test-lib/docker-helper';
|
|
import Docker from 'dockerode';
|
|
import { setTimeout } from 'timers/promises';
|
|
|
|
describe('device-state', () => {
|
|
const docker = new Docker();
|
|
before(async () => {
|
|
await config.initialized();
|
|
|
|
// Set the device uuid
|
|
await config.set({ uuid: 'local' });
|
|
initializeContractRequirements({
|
|
supervisorVersion: '11.0.0',
|
|
deviceType: 'intel-nuc',
|
|
deviceArch: 'amd64',
|
|
});
|
|
});
|
|
|
|
after(async () => {
|
|
await docker.pruneImages({ filters: { dangling: { false: true } } });
|
|
});
|
|
|
|
it('loads a target state from an apps.json file and saves it as target state, then returns it', async () => {
|
|
const localFs = await testfs(
|
|
{ '/data/apps.json': testfs.from('test/data/apps.json') },
|
|
{ cleanup: ['/data/apps.json.preloaded'] },
|
|
).enable();
|
|
|
|
// The image needs to exist before the test
|
|
const dockerImageId = await createDockerImage(
|
|
'registry2.resin.io/superapp/abcdef:latest',
|
|
['io.balena.testing=1'],
|
|
docker,
|
|
);
|
|
|
|
const appsJson = '/data/apps.json';
|
|
await expect(
|
|
fs.access(appsJson),
|
|
'apps.json exists before loading the target',
|
|
).to.not.be.rejected;
|
|
await loadTargetFromFile(appsJson);
|
|
const targetState = await deviceState.getTarget();
|
|
await expect(
|
|
fs.access(appsJsonBackup(appsJson)),
|
|
'apps.json.preloaded is created after loading the target',
|
|
).to.not.be.rejected;
|
|
expect(targetState)
|
|
.to.have.property('local')
|
|
.that.has.property('config')
|
|
.that.has.property('HOST_CONFIG_gpu_mem')
|
|
.that.equals('256');
|
|
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(dockerImageId);
|
|
expect(app.services[0].config)
|
|
.to.have.property('labels')
|
|
.that.has.property('io.balena.something')
|
|
.that.equals('bar');
|
|
|
|
// Remove the image
|
|
await docker.getImage(dockerImageId).remove();
|
|
await localFs.restore();
|
|
});
|
|
|
|
it('stores info for pinning a device after loading an apps.json with a pinDevice field', async () => {
|
|
const localFs = await testfs(
|
|
{ '/data/apps.json': testfs.from('test/data/apps-pin.json') },
|
|
{ cleanup: ['/data/apps.json.preloaded'] },
|
|
).enable();
|
|
|
|
// The image needs to exist before the test
|
|
const dockerImageId = await createDockerImage(
|
|
'registry2.resin.io/superapp/abcdef:latest',
|
|
['io.balena.testing=1'],
|
|
docker,
|
|
);
|
|
|
|
const appsJson = '/data/apps.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;
|
|
|
|
// Remove the image
|
|
await docker.getImage(dockerImageId).remove();
|
|
await localFs.restore();
|
|
});
|
|
|
|
it('emits a change event when a new state is reported', (done) => {
|
|
deviceState.once('change', done);
|
|
deviceState.reportCurrentState({ someStateDiff: 'someValue' } as any);
|
|
});
|
|
|
|
it('writes the target state to the db with some extra defaults', async () => {
|
|
await deviceState.setTarget({
|
|
local: {
|
|
name: 'aDeviceWithDifferentName',
|
|
config: {
|
|
RESIN_HOST_CONFIG_gpu_mem: '512',
|
|
},
|
|
apps: {
|
|
myapp: {
|
|
id: 1234,
|
|
name: 'superapp',
|
|
class: 'fleet',
|
|
releases: {
|
|
afafafa: {
|
|
id: 2,
|
|
services: {
|
|
aservice: {
|
|
id: 23,
|
|
image_id: 12345,
|
|
image: 'registry2.resin.io/superapp/edfabc',
|
|
environment: {
|
|
FOO: 'bar',
|
|
},
|
|
labels: {},
|
|
},
|
|
anotherService: {
|
|
id: 24,
|
|
image_id: 12346,
|
|
image: 'registry2.resin.io/superapp/afaff',
|
|
environment: {
|
|
FOO: 'bro',
|
|
},
|
|
labels: {},
|
|
},
|
|
},
|
|
volumes: {},
|
|
networks: {},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as TargetState);
|
|
const targetState = await deviceState.getTarget();
|
|
|
|
expect(targetState)
|
|
.to.have.property('local')
|
|
.that.has.property('config')
|
|
.that.has.property('HOST_CONFIG_gpu_mem')
|
|
.that.equals('512');
|
|
|
|
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('commit').that.equals('afafafa');
|
|
expect(app).to.have.property('services').that.is.an('array').with.length(2);
|
|
expect(app.services[0])
|
|
.to.have.property('config')
|
|
.that.has.property('image')
|
|
.that.equals('registry2.resin.io/superapp/edfabc:latest');
|
|
expect(app.services[0].config)
|
|
.to.have.property('environment')
|
|
.that.has.property('FOO')
|
|
.that.equals('bar');
|
|
expect(app.services[1])
|
|
.to.have.property('config')
|
|
.that.has.property('image')
|
|
.that.equals('registry2.resin.io/superapp/afaff:latest');
|
|
expect(app.services[1].config)
|
|
.to.have.property('environment')
|
|
.that.has.property('FOO')
|
|
.that.equals('bro');
|
|
});
|
|
|
|
it('does not allow setting an invalid target state', () => {
|
|
// v2 state should be rejected
|
|
return expect(
|
|
deviceState.setTarget({
|
|
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',
|
|
environment: {
|
|
' FOO': 'bar',
|
|
},
|
|
labels: {},
|
|
},
|
|
24: {
|
|
serviceId: '24',
|
|
serviceName: 'anotherService',
|
|
imageId: '12346',
|
|
image: 'registry2.resin.io/superapp/afaff',
|
|
environment: {
|
|
FOO: 'bro',
|
|
},
|
|
labels: {},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as any),
|
|
).to.be.rejected;
|
|
});
|
|
|
|
it('allows triggering applying the target state', async () => {
|
|
const applyTargetStub = sinon
|
|
.stub(deviceState, 'applyTarget')
|
|
.returns(Promise.resolve());
|
|
|
|
deviceState.triggerApplyTarget({ force: true });
|
|
expect(applyTargetStub).to.not.be.called;
|
|
|
|
await setTimeout(1000);
|
|
expect(applyTargetStub).to.be.calledWith({
|
|
force: true,
|
|
initial: false,
|
|
});
|
|
applyTargetStub.restore();
|
|
});
|
|
|
|
it('accepts a target state with an valid contract', async () => {
|
|
await deviceState.setTarget({
|
|
local: {
|
|
name: 'aDeviceWithDifferentName',
|
|
config: {},
|
|
apps: {
|
|
myapp: {
|
|
id: 1234,
|
|
name: 'superapp',
|
|
class: 'fleet',
|
|
releases: {
|
|
one: {
|
|
id: 2,
|
|
services: {
|
|
valid: {
|
|
id: 23,
|
|
image_id: 12345,
|
|
image: 'registry2.resin.io/superapp/valid',
|
|
environment: {},
|
|
labels: {},
|
|
},
|
|
alsoValid: {
|
|
id: 24,
|
|
image_id: 12346,
|
|
image: 'registry2.resin.io/superapp/afaff',
|
|
contract: {
|
|
type: 'sw.container',
|
|
slug: 'supervisor-version',
|
|
name: 'Enforce supervisor version',
|
|
requires: [
|
|
{
|
|
type: 'sw.supervisor',
|
|
version: '>=11.0.0',
|
|
},
|
|
],
|
|
},
|
|
environment: {},
|
|
labels: {},
|
|
},
|
|
},
|
|
volumes: {},
|
|
networks: {},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as TargetState);
|
|
const targetState = await deviceState.getTarget();
|
|
const app = targetState.local.apps[1234];
|
|
expect(app).to.have.property('appName').that.equals('superapp');
|
|
expect(app).to.have.property('commit').that.equals('one');
|
|
// Only a single service should be on the target state
|
|
expect(app).to.have.property('services').that.is.an('array').with.length(2);
|
|
expect(app.services[1])
|
|
.that.has.property('serviceName')
|
|
.that.equals('alsoValid');
|
|
});
|
|
|
|
it('accepts a target state with an invalid contract for an optional container', async () => {
|
|
await deviceState.setTarget({
|
|
local: {
|
|
name: 'aDeviceWithDifferentName',
|
|
config: {},
|
|
apps: {
|
|
myapp: {
|
|
id: 1234,
|
|
name: 'superapp',
|
|
class: 'fleet',
|
|
releases: {
|
|
one: {
|
|
id: 2,
|
|
services: {
|
|
valid: {
|
|
id: 23,
|
|
image_id: 12345,
|
|
image: 'registry2.resin.io/superapp/valid',
|
|
environment: {},
|
|
labels: {},
|
|
},
|
|
invalidButOptional: {
|
|
id: 24,
|
|
image_id: 12346,
|
|
image: 'registry2.resin.io/superapp/afaff',
|
|
contract: {
|
|
type: 'sw.container',
|
|
slug: 'supervisor-version',
|
|
name: 'Enforce supervisor version',
|
|
requires: [
|
|
{
|
|
type: 'sw.supervisor',
|
|
version: '>=12.0.0',
|
|
},
|
|
],
|
|
},
|
|
environment: {},
|
|
labels: {
|
|
'io.balena.features.optional': 'true',
|
|
},
|
|
},
|
|
},
|
|
volumes: {},
|
|
networks: {},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as TargetState);
|
|
const targetState = await deviceState.getTarget();
|
|
const app = targetState.local.apps[1234];
|
|
expect(app).to.have.property('appName').that.equals('superapp');
|
|
expect(app).to.have.property('commit').that.equals('one');
|
|
expect(app).to.have.property('isRejected').that.is.false;
|
|
// Only a single service should be on the target state
|
|
expect(app).to.have.property('services').that.is.an('array').with.length(1);
|
|
expect(app.services[0])
|
|
.that.has.property('serviceName')
|
|
.that.equals('valid');
|
|
});
|
|
|
|
it('accepts target state with invalid contract setting isRejected to true and resets state when a valid target is received', async () => {
|
|
// Set the rejected target
|
|
await expect(
|
|
deviceState.setTarget({
|
|
local: {
|
|
name: 'aDeviceWithDifferentName',
|
|
config: {},
|
|
apps: {
|
|
myapp: {
|
|
id: 1234,
|
|
name: 'superapp',
|
|
class: 'fleet',
|
|
releases: {
|
|
one: {
|
|
id: 2,
|
|
services: {
|
|
valid: {
|
|
id: 23,
|
|
image_id: 12345,
|
|
image: 'registry2.resin.io/superapp/valid',
|
|
environment: {},
|
|
labels: {},
|
|
},
|
|
invalid: {
|
|
id: 24,
|
|
image_id: 12346,
|
|
image: 'registry2.resin.io/superapp/afaff',
|
|
contract: {
|
|
type: 'sw.container',
|
|
slug: 'supervisor-version',
|
|
name: 'Enforce supervisor version',
|
|
requires: [
|
|
{
|
|
type: 'arch.sw',
|
|
slug: 'armv7hf',
|
|
},
|
|
],
|
|
},
|
|
environment: {},
|
|
labels: {},
|
|
},
|
|
},
|
|
volumes: {},
|
|
networks: {},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as TargetState),
|
|
).to.not.be.rejected;
|
|
const targetState = await deviceState.getTarget();
|
|
const app = targetState.local.apps[1234];
|
|
expect(app).to.have.property('appName').that.equals('superapp');
|
|
expect(app).to.have.property('commit').that.equals('one');
|
|
expect(app).to.have.property('isRejected').that.is.true;
|
|
|
|
// Now set a good target for the same app
|
|
await deviceState.setTarget({
|
|
local: {
|
|
name: 'aDeviceWithDifferentName',
|
|
config: {},
|
|
apps: {
|
|
myapp: {
|
|
id: 1234,
|
|
name: 'superapp',
|
|
class: 'fleet',
|
|
releases: {
|
|
two: {
|
|
id: 2,
|
|
services: {
|
|
valid: {
|
|
id: 23,
|
|
image_id: 12345,
|
|
image: 'registry2.resin.io/superapp/valid',
|
|
environment: {},
|
|
labels: {},
|
|
},
|
|
alsoValid: {
|
|
id: 24,
|
|
image_id: 12346,
|
|
image: 'registry2.resin.io/superapp/afaff',
|
|
contract: {
|
|
type: 'sw.container',
|
|
slug: 'supervisor-version',
|
|
name: 'Enforce supervisor version',
|
|
requires: [
|
|
{
|
|
type: 'sw.supervisor',
|
|
version: '>=11.0.0',
|
|
},
|
|
],
|
|
},
|
|
environment: {},
|
|
labels: {},
|
|
},
|
|
},
|
|
volumes: {},
|
|
networks: {},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as TargetState);
|
|
|
|
const targetState2 = await deviceState.getTarget();
|
|
const app2 = targetState2.local.apps[1234];
|
|
expect(app2).to.have.property('commit').that.equals('two');
|
|
expect(app2).to.have.property('isRejected').that.is.false;
|
|
});
|
|
|
|
// 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';
|
|
sinon
|
|
.stub(updateLock, 'abortIfHUPInProgress')
|
|
.throws(new UpdatesLockedError(testErrMsg));
|
|
|
|
await expect(deviceState.shutdown({ reboot: true }))
|
|
.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 sinon.SinonStub).restore();
|
|
});
|
|
});
|