balena-supervisor/test/legacy/22-local-mode.spec.ts

428 lines
13 KiB
TypeScript
Raw Normal View History

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"/,
);
}
});
});
});
});