balena-supervisor/test/integration/device-state.spec.ts
Christina Ying Wang 3d3f659f16 Delete apps not in target from db by appUuid instead of appId
Resolve an issue in balenaMachine instances that were installed at <v14.1.0,
in which a Supervisor app with random UUID is kept in the target db due to its appId
being the same, even after the BM instance has upgraded to v14.1.0 which patches
the correct reserved Supervisor app UUIDs in. This results in two Supervisors running
on devices under the BM instance which persists after BM upgrade.

See: https://balena.fibery.io/search/T7ozi#Inputs/Pattern/Two-supervisors-are-running-on-device-3370
Change-type: patch
Signed-off-by: Christina Ying Wang <christina@balena.io>
2024-11-04 14:15:55 -08:00

635 lines
18 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;
});
// Resolves an issue in balenaMachine instances that were installed at <v14.1.0,
// in which a Supervisor app with random UUID is kept in the target db due to its appId
// being the same, even after the BM instance has upgraded to v14.1.0 which patches
// the correct reserved Supervisor app UUIDs in. This results in two Supervisors running
// on devices under the BM instance which persists after BM upgrade.
// See: https://balena.fibery.io/search/T7ozi#Inputs/Pattern/Two-supervisors-are-running-on-device-3370
it('removes apps with UUIDs not in target but where appId are the same as those in target, from target state', async () => {
const WRONG_UUID = '50e35121417640b1b28a680504e4039a';
const RIGHT_UUID = '52e35121417640b1b28a680504e4039b';
const APP_ID = 1667442;
await deviceState.setTarget({
local: {
name: 'trixie',
config: {},
apps: {
// Supervisor has the wrong app UUID initially, but the right appId
[WRONG_UUID]: {
id: APP_ID,
name: 'amd64-supervisor',
class: 'app',
releases: {
deadbeef: {
id: 1,
services: {
'balena-test-supervisor': {
id: 2,
image_id: 3,
image: 'registry2.balena-cloud.com/deadca1f',
environment: {},
labels: {},
},
},
volumes: {},
networks: {},
},
},
},
},
},
} as TargetState);
const targetState = await deviceState.getTarget();
expect(targetState)
.to.have.property('local')
.that.has.property('apps')
.that.has.property(APP_ID.toString())
.that.is.an('object');
const testSupervisor = targetState.local.apps[APP_ID];
expect(testSupervisor)
.to.have.property('appName')
.that.equals('amd64-supervisor');
expect(testSupervisor).to.have.property('appUuid').that.equals(WRONG_UUID);
expect(testSupervisor).to.have.property('commit').that.equals('deadbeef');
expect(testSupervisor)
.to.have.property('services')
.that.is.an('array')
.with.length(1);
expect(testSupervisor.services[0])
.to.have.property('config')
.that.has.property('image')
.that.equals('registry2.balena-cloud.com/deadca1f:latest');
await deviceState.setTarget({
local: {
name: 'trixie',
config: {},
apps: {
// Supervisor apps have been patched in user's BM to have
// the right app UUID, so this is now sent in the target state
[RIGHT_UUID]: {
id: APP_ID,
name: 'amd64-supervisor',
class: 'app',
releases: {
deadbeef: {
id: 1,
services: {
'balena-test-supervisor': {
id: 2,
image_id: 3,
image: 'registry2.balena-cloud.com/deadca1f',
environment: {},
labels: {},
},
},
volumes: {},
networks: {},
},
},
},
},
},
} as TargetState);
const targetState2 = await deviceState.getTarget();
expect(targetState2)
.to.have.property('local')
.that.has.property('apps')
.that.has.property(APP_ID.toString())
.that.is.an('object');
const testSupervisor2 = targetState2.local.apps[APP_ID];
expect(testSupervisor2)
.to.have.property('appName')
.that.equals('amd64-supervisor');
// Db apps whose UUIDs aren't in target are removed
// (As opposed to before, where appIds were compared for removal)
expect(testSupervisor2).to.have.property('appUuid').that.equals(RIGHT_UUID);
expect(testSupervisor2).to.have.property('commit').that.equals('deadbeef');
expect(testSupervisor2)
.to.have.property('services')
.that.is.an('array')
.with.length(1);
expect(testSupervisor2.services[0])
.to.have.property('config')
.that.has.property('image')
.that.equals('registry2.balena-cloud.com/deadca1f:latest');
});
// 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();
});
});