mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2024-12-23 07:22:24 +00:00
96418d55b5
Change-type: patch
427 lines
13 KiB
TypeScript
427 lines
13 KiB
TypeScript
import { expect } from 'chai';
|
|
import * as Docker from 'dockerode';
|
|
import * as sinon from 'sinon';
|
|
|
|
import * as db from '~/src/db';
|
|
import { docker } from '~/lib/docker-utils';
|
|
import LocalModeManager, {
|
|
EngineSnapshot,
|
|
EngineSnapshotRecord,
|
|
} from '~/src/local-mode';
|
|
import ShortStackError from '~/test-lib/errors';
|
|
|
|
describe('LocalModeManager', () => {
|
|
let localMode: LocalModeManager;
|
|
let dockerStub: sinon.SinonStubbedInstance<typeof docker>;
|
|
|
|
const supervisorContainerId = 'super-container-1';
|
|
|
|
const recordsCount = async () =>
|
|
await db
|
|
.models('engineSnapshot')
|
|
.count('* as cnt')
|
|
.first()
|
|
.then((r) => r.cnt);
|
|
|
|
// Cleanup the database (to make sure nothing is left since last tests).
|
|
beforeEach(async () => {
|
|
await db.models('engineSnapshot').delete();
|
|
});
|
|
|
|
before(async () => {
|
|
await db.initialized();
|
|
|
|
dockerStub = sinon.stub(docker);
|
|
|
|
localMode = new LocalModeManager(supervisorContainerId);
|
|
});
|
|
|
|
after(async () => {
|
|
sinon.restore();
|
|
});
|
|
|
|
describe('EngineSnapshot', () => {
|
|
it('can calculate a diff', () => {
|
|
const original = new EngineSnapshot(
|
|
['c1', 'c2'],
|
|
['i1'],
|
|
['v1', 'v2'],
|
|
['nn'],
|
|
);
|
|
const newOne = new EngineSnapshot(
|
|
['c2', 'c3'],
|
|
['i1', 'i2', 'i3'],
|
|
['v1'],
|
|
[],
|
|
);
|
|
const diff = newOne.diff(original);
|
|
|
|
expect(diff.containers).to.deep.equal(['c3']);
|
|
expect(diff.images).to.deep.equal(['i2', 'i3']);
|
|
expect(diff.volumes).to.deep.equal([]);
|
|
expect(diff.networks).to.deep.equal([]);
|
|
});
|
|
});
|
|
|
|
describe('engine snapshots collection', () => {
|
|
before(() => {
|
|
// Stub the engine to return images, containers, volumes, and networks.
|
|
dockerStub.listImages.returns(
|
|
Promise.resolve([
|
|
{ Id: 'image-1' } as Docker.ImageInfo,
|
|
{ Id: 'image-2' } as Docker.ImageInfo,
|
|
]),
|
|
);
|
|
dockerStub.listContainers.returns(
|
|
Promise.resolve([
|
|
{ Id: 'container-1' } as Docker.ContainerInfo,
|
|
{ Id: 'container-2' } as Docker.ContainerInfo,
|
|
]),
|
|
);
|
|
dockerStub.listVolumes.returns(
|
|
Promise.resolve({
|
|
Volumes: [
|
|
{ Name: 'volume-1' } as Docker.VolumeInspectInfo,
|
|
{ Name: 'volume-2' } as Docker.VolumeInspectInfo,
|
|
],
|
|
Warnings: [],
|
|
}),
|
|
);
|
|
dockerStub.listNetworks.returns(
|
|
Promise.resolve([{ Id: 'network-1' }, { Id: 'network-2' }]),
|
|
);
|
|
});
|
|
|
|
it('collects all necessary engine entities', async () => {
|
|
const snapshotRecord = await localMode.collectEngineSnapshot();
|
|
|
|
expect(snapshotRecord.snapshot.containers).to.include(
|
|
'container-1',
|
|
'container-2',
|
|
);
|
|
expect(snapshotRecord.snapshot.images).to.include('image-1', 'image-2');
|
|
expect(snapshotRecord.snapshot.volumes).to.include(
|
|
'volume-1',
|
|
'volume-2',
|
|
);
|
|
expect(snapshotRecord.snapshot.networks).to.include(
|
|
'network-1',
|
|
'network-2',
|
|
);
|
|
});
|
|
|
|
it('marks snapshot with a timestamp', async () => {
|
|
const startTime = new Date();
|
|
const snapshotRecord = await localMode.collectEngineSnapshot();
|
|
expect(snapshotRecord.timestamp).to.be.at.least(startTime);
|
|
});
|
|
|
|
describe('local mode switch', () => {
|
|
// Info returned when we inspect our own container.
|
|
const supervisorContainer = {
|
|
Id: 'super-container-1',
|
|
State: {
|
|
Status: 'running',
|
|
Running: true,
|
|
},
|
|
Image: 'super-image-1',
|
|
HostConfig: {
|
|
ContainerIDFile: '/resin-data/balena-supervisor/container-id',
|
|
},
|
|
Mounts: [
|
|
{
|
|
Type: 'volume',
|
|
Name: 'super-volume-1',
|
|
},
|
|
],
|
|
NetworkSettings: {
|
|
Networks: {
|
|
'some-name': {
|
|
NetworkID: 'super-network-1',
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const storeCurrentSnapshot = async (
|
|
containers: string[],
|
|
images: string[],
|
|
volumes: string[],
|
|
networks: string[],
|
|
) => {
|
|
await localMode.storeEngineSnapshot(
|
|
new EngineSnapshotRecord(
|
|
new EngineSnapshot(containers, images, volumes, networks),
|
|
new Date(),
|
|
),
|
|
);
|
|
};
|
|
|
|
interface EngineStubbedObject {
|
|
remove(): Promise<void>;
|
|
inspect(): Promise<any>;
|
|
}
|
|
|
|
// Stub get<Object> methods on docker, so we can verify remove calls.
|
|
const stubEngineObjectMethods = (
|
|
removeThrows: boolean,
|
|
): Array<sinon.SinonStubbedInstance<EngineStubbedObject>> => {
|
|
const resArray: Array<sinon.SinonStubbedInstance<EngineStubbedObject>> =
|
|
[];
|
|
|
|
const stub = <T>(
|
|
c: sinon.StubbableType<EngineStubbedObject>,
|
|
type: string,
|
|
) => {
|
|
const res = sinon.createStubInstance(c);
|
|
if (removeThrows) {
|
|
res.remove.rejects(new ShortStackError(`error removing ${type}`));
|
|
} else {
|
|
res.remove.resolves();
|
|
}
|
|
|
|
if (c === Docker.Container) {
|
|
res.inspect.resolves(supervisorContainer);
|
|
}
|
|
|
|
resArray.push(res);
|
|
return res as unknown as T;
|
|
};
|
|
dockerStub.getImage.returns(stub(Docker.Image, 'image'));
|
|
dockerStub.getContainer.returns(stub(Docker.Container, 'container'));
|
|
dockerStub.getVolume.returns(stub(Docker.Volume, 'volume'));
|
|
dockerStub.getNetwork.returns(stub(Docker.Network, 'network'));
|
|
return resArray;
|
|
};
|
|
|
|
afterEach(() => {
|
|
dockerStub.getImage.resetHistory();
|
|
dockerStub.getContainer.resetHistory();
|
|
dockerStub.getVolume.resetHistory();
|
|
dockerStub.getNetwork.resetHistory();
|
|
});
|
|
|
|
it('stores new snapshot on local mode enter', async () => {
|
|
await localMode.handleLocalModeStateChange(true);
|
|
|
|
const snapshot = await localMode.retrieveLatestSnapshot();
|
|
expect(snapshot).to.be.not.null;
|
|
});
|
|
|
|
it('deletes newly created objects on local mode exit', async () => {
|
|
const removeStubs = stubEngineObjectMethods(false);
|
|
// All the objects returned by list<Objects> are not included into this snapshot.
|
|
// Hence, removal should be called twice (stubbed methods return 2 objects per type).
|
|
await storeCurrentSnapshot(
|
|
['previous-container'],
|
|
['previous-image'],
|
|
['previous-volume'],
|
|
['previous-network'],
|
|
);
|
|
|
|
await localMode.handleLocalModeStateChange(false);
|
|
|
|
removeStubs.forEach((s) => expect(s.remove.calledTwice).to.be.true);
|
|
});
|
|
|
|
it('keeps objects from the previous snapshot on local mode exit', async () => {
|
|
const removeStubs = stubEngineObjectMethods(false);
|
|
// With this snapshot, only <object>-2 must be removed from the engine.
|
|
await storeCurrentSnapshot(
|
|
['container-1'],
|
|
['image-1'],
|
|
['volume-1'],
|
|
['network-1'],
|
|
);
|
|
|
|
await localMode.handleLocalModeStateChange(false);
|
|
|
|
expect(dockerStub.getImage.calledWithExactly('image-2')).to.be.true;
|
|
expect(dockerStub.getContainer.calledWithExactly('container-2')).to.be
|
|
.true;
|
|
expect(dockerStub.getVolume.calledWithExactly('volume-2')).to.be.true;
|
|
expect(dockerStub.getNetwork.calledWithExactly('network-2')).to.be.true;
|
|
removeStubs.forEach((s) => expect(s.remove.calledOnce).to.be.true);
|
|
});
|
|
|
|
it('logs but consumes cleanup errors on local mode exit', async () => {
|
|
const removeStubs = stubEngineObjectMethods(true);
|
|
// This snapshot will cause the logic to remove everything.
|
|
await storeCurrentSnapshot([], [], [], []);
|
|
|
|
// This should not throw.
|
|
await localMode.handleLocalModeStateChange(false);
|
|
|
|
// Even though remove method throws, we still attempt all removals.
|
|
removeStubs.forEach((s) => expect(s.remove.calledTwice).to.be.true);
|
|
});
|
|
|
|
it('skips cleanup without previous snapshot on local mode exit', async () => {
|
|
const removeStubs = stubEngineObjectMethods(false);
|
|
|
|
await localMode.handleLocalModeStateChange(false);
|
|
|
|
expect(dockerStub.getImage.notCalled).to.be.true;
|
|
expect(dockerStub.getContainer.notCalled).to.be.true;
|
|
expect(dockerStub.getVolume.notCalled).to.be.true;
|
|
expect(dockerStub.getNetwork.notCalled).to.be.true;
|
|
removeStubs.forEach((s) => expect(s.remove.notCalled).to.be.true);
|
|
});
|
|
|
|
it('can be awaited', async () => {
|
|
const removeStubs = stubEngineObjectMethods(false);
|
|
await storeCurrentSnapshot([], [], [], []);
|
|
|
|
// Run asynchronously (like on config change).
|
|
localMode.startLocalModeChangeHandling(false);
|
|
|
|
// Await like it's done by DeviceState.
|
|
await localMode.switchCompletion();
|
|
|
|
removeStubs.forEach((s) => expect(s.remove.calledTwice).to.be.true);
|
|
});
|
|
|
|
it('cleans the last snapshot so that nothing is done on restart', async () => {
|
|
const removeStubs = stubEngineObjectMethods(false);
|
|
await storeCurrentSnapshot([], [], [], []);
|
|
|
|
await localMode.handleLocalModeStateChange(false);
|
|
|
|
// The database should be empty now.
|
|
expect(await recordsCount()).to.be.equal(0);
|
|
|
|
// This has to be no ops.
|
|
await localMode.handleLocalModeStateChange(false);
|
|
// So our stubs must be called only once.
|
|
// We delete 2 objects of each type during the first call, so number of getXXX and remove calls is 2.
|
|
expect(dockerStub.getImage.callCount).to.be.equal(2);
|
|
expect(dockerStub.getContainer.callCount).to.be.equal(3); // +1 for supervisor inspect call.
|
|
expect(dockerStub.getVolume.callCount).to.be.equal(2);
|
|
expect(dockerStub.getNetwork.callCount).to.be.equal(2);
|
|
removeStubs.forEach((s) => expect(s.remove.callCount).to.be.equal(2));
|
|
});
|
|
|
|
it('skips cleanup in case of data corruption', async () => {
|
|
const removeStubs = stubEngineObjectMethods(false);
|
|
|
|
await db.models('engineSnapshot').insert({
|
|
snapshot: 'bad json',
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
|
|
localMode.startLocalModeChangeHandling(false);
|
|
await localMode.switchCompletion();
|
|
|
|
expect(dockerStub.getImage.notCalled).to.be.true;
|
|
expect(dockerStub.getContainer.notCalled).to.be.true;
|
|
expect(dockerStub.getVolume.notCalled).to.be.true;
|
|
expect(dockerStub.getNetwork.notCalled).to.be.true;
|
|
removeStubs.forEach((s) => expect(s.remove.notCalled).to.be.true);
|
|
});
|
|
|
|
describe('with supervisor being updated', () => {
|
|
beforeEach(() => {
|
|
// We make supervisor own resources to match currently listed objects.
|
|
supervisorContainer.Id = 'container-1';
|
|
supervisorContainer.Image = 'image-1';
|
|
supervisorContainer.NetworkSettings.Networks['some-name'].NetworkID =
|
|
'network-1';
|
|
supervisorContainer.Mounts[0].Name = 'volume-1';
|
|
});
|
|
|
|
it('does not delete its own object', async () => {
|
|
const removeStubs = stubEngineObjectMethods(false);
|
|
// All the current engine objects will be new, including container-1 which is the supervisor.
|
|
await storeCurrentSnapshot(
|
|
['previous-container'],
|
|
['previous-image'],
|
|
['previous-volume'],
|
|
['previous-network'],
|
|
);
|
|
|
|
await localMode.handleLocalModeStateChange(false);
|
|
|
|
// Ensure we inspect our own container.
|
|
const [, containerStub] = removeStubs;
|
|
expect(containerStub.inspect.calledOnce).to.be.true;
|
|
|
|
// Current engine objects include 2 entities of each type.
|
|
// Container-1, network-1, image-1, and volume-1 are resources associated with currently running supervisor.
|
|
// Only xxx-2 objects must be deleted.
|
|
removeStubs.forEach((s) => expect(s.remove.calledOnce).to.be.true);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('engine snapshot storage', () => {
|
|
const recordSample = new EngineSnapshotRecord(
|
|
new EngineSnapshot(
|
|
['c1', 'c2'],
|
|
['i1', 'i2'],
|
|
['v1', 'v2'],
|
|
['n1', 'n2'],
|
|
),
|
|
new Date(),
|
|
);
|
|
|
|
it('returns null when snapshot is not stored', async () => {
|
|
expect(await recordsCount()).to.equal(0);
|
|
const retrieved = await localMode.retrieveLatestSnapshot();
|
|
expect(retrieved).to.be.null;
|
|
});
|
|
|
|
it('stores snapshot and retrieves from the db', async () => {
|
|
await localMode.storeEngineSnapshot(recordSample);
|
|
const retrieved = await localMode.retrieveLatestSnapshot();
|
|
expect(retrieved).to.be.deep.equal(recordSample);
|
|
});
|
|
|
|
it('rewrites previous snapshot', async () => {
|
|
await localMode.storeEngineSnapshot(recordSample);
|
|
await localMode.storeEngineSnapshot(recordSample);
|
|
await localMode.storeEngineSnapshot(recordSample);
|
|
expect(await recordsCount()).to.equal(1);
|
|
});
|
|
|
|
describe('in case of data corruption', () => {
|
|
beforeEach(async () => {
|
|
await db.models('engineSnapshot').delete();
|
|
});
|
|
|
|
it('deals with snapshot data corruption', async () => {
|
|
// Write bad data to simulate corruption.
|
|
await db.models('engineSnapshot').insert({
|
|
snapshot: 'bad json',
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
|
|
try {
|
|
const result = await localMode.retrieveLatestSnapshot();
|
|
expect(result).to.not.exist;
|
|
} catch (e: any) {
|
|
expect(e.message).to.match(/Cannot parse snapshot data.*"bad json"/);
|
|
}
|
|
});
|
|
|
|
it('deals with snapshot timestamp corruption', async () => {
|
|
// Write bad data to simulate corruption.
|
|
await db.models('engineSnapshot').insert({
|
|
snapshot:
|
|
'{"containers": [], "images": [], "volumes": [], "networks": []}',
|
|
timestamp: 'bad timestamp',
|
|
});
|
|
|
|
try {
|
|
const result = await localMode.retrieveLatestSnapshot();
|
|
expect(result).to.not.exist;
|
|
} catch (e: any) {
|
|
expect(e.message).to.match(
|
|
/Cannot parse snapshot data.*"bad timestamp"/,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
});
|