Migrate volume-manager tests to integration

Now the tests are ran against the actual docker engine instead of
against mockerode.

The new tests actually caught a bug in
`volumeManager.removeOrphanedVolumes`, where that function would try to
remove volumes for stopped containers, causing an exception.
This commit also fixes that bug.
This commit is contained in:
Felipe Lalanne 2022-08-25 17:15:48 -04:00
parent 18c2f8cec9
commit a69fbf6eac
4 changed files with 334 additions and 361 deletions

View File

@ -117,7 +117,7 @@ export async function removeOrphanedVolumes(
// *all* containers. This means we don't remove // *all* containers. This means we don't remove
// something that's part of a sideloaded container // something that's part of a sideloaded container
const [dockerContainers, dockerVolumes] = await Promise.all([ const [dockerContainers, dockerVolumes] = await Promise.all([
docker.listContainers(), docker.listContainers({ all: true }),
docker.listVolumes(), docker.listVolumes(),
]); ]);

View File

@ -0,0 +1,305 @@
import { expect } from 'chai';
import * as sinon from 'sinon';
import * as volumeManager from '~/src/compose/volume-manager';
import Volume from '~/src/compose/volume';
import { createDockerImage } from '~/test-lib/docker-helper';
import * as Docker from 'dockerode';
describe('compose/volume-manager', () => {
const docker = new Docker();
after(async () => {
await docker.pruneContainers();
await docker.pruneVolumes();
await docker.pruneImages();
});
describe('Retrieving volumes from the engine', () => {
it('gets all supervised Volumes', async () => {
// Setup volume data
await Promise.all([
docker.createVolume({
Name: Volume.generateDockerName(1, 'redis'),
// Recently created volumes contain io.balena.supervised label
Labels: { 'io.balena.supervised': '1' },
}),
docker.createVolume({
Name: Volume.generateDockerName(1, 'mysql'),
// Recently created volumes contain io.balena.supervised label and app-uuid
Labels: {
'io.balena.supervised': '1',
'io.balena.app-uuid': 'deadbeef',
},
}),
docker.createVolume({
Name: Volume.generateDockerName(2, 'backend'),
// Old Volumes will not have labels
}),
// Volume not created by the Supervisor
docker.createVolume({ Name: 'user_created_volume' }),
docker.createVolume({
Name: 'decoy',
// Added decoy to really test the inference (should not return)
Labels: { 'io.balena.supervised': '1' },
}),
]);
// Perform test
await expect(volumeManager.getAll()).to.eventually.have.deep.members([
{
appId: 1,
appUuid: undefined,
config: {
driver: 'local',
driverOpts: {},
labels: {
'io.balena.supervised': '1',
},
},
name: 'redis',
},
{
appId: 1,
appUuid: 'deadbeef',
config: {
driver: 'local',
driverOpts: {},
labels: {
'io.balena.supervised': '1',
'io.balena.app-uuid': 'deadbeef',
},
},
name: 'mysql',
},
{
appId: 2,
appUuid: undefined,
config: {
driver: 'local',
driverOpts: {},
labels: {},
},
name: 'backend',
},
]);
// Cleanup volumes
await Promise.all([
docker.getVolume(Volume.generateDockerName(1, 'redis')).remove(),
docker.getVolume(Volume.generateDockerName(1, 'mysql')).remove(),
docker.getVolume(Volume.generateDockerName(2, 'backend')).remove(),
docker.getVolume('user_created_volume').remove(),
docker.getVolume('decoy').remove(),
]);
});
it('can parse null Volumes', async () => {
// Perform test with no volumes
await expect(volumeManager.getAll()).to.eventually.deep.equal([]);
});
it('gets the volume for specific application', async () => {
// Setup volume data
await Promise.all([
docker.createVolume({
Name: Volume.generateDockerName(111, 'app'),
Labels: {
'io.balena.supervised': '1',
},
}),
docker.createVolume({
Name: Volume.generateDockerName(222, 'otherApp'),
Labels: {
'io.balena.supervised': '1',
},
}),
]);
// Perform test
await expect(volumeManager.getAllByAppId(111)).to.eventually.deep.equal([
{
appId: 111,
appUuid: undefined,
config: {
driver: 'local',
driverOpts: {},
labels: {
'io.balena.supervised': '1',
},
},
name: 'app',
},
]);
// Cleanup volumes
await Promise.all([
docker.getVolume(Volume.generateDockerName(111, 'app')).remove(),
docker.getVolume(Volume.generateDockerName(222, 'otherApp')).remove(),
]);
});
});
describe('Creating volumes', () => {
it('creates a volume if it does not exist', async () => {
// The volume does not exist on the engine before
await expect(
docker.getVolume(Volume.generateDockerName(111, 'main')).inspect(),
).to.be.rejected;
// Volume to create
const volume = Volume.fromComposeObject('main', 111, 'deadbeef', {});
// Create volume
await volumeManager.create(volume);
// Check the volume should have been created
await expect(
docker.getVolume(Volume.generateDockerName(111, 'main')).inspect(),
).to.not.be.rejected;
// Cleanup volumes
await Promise.all([
docker.getVolume(Volume.generateDockerName(111, 'main')).remove(),
]);
});
it('does not try to create a volume that already exists', async () => {
// Setup volume data
await docker.createVolume({
Name: Volume.generateDockerName(111, 'main'),
Labels: {
'io.balena.supervised': '1',
},
});
// Create compose object for volume already set up in mock engine
const volume = Volume.fromComposeObject('main', 111, 'deadbeef', {});
sinon.spy(volume, 'create');
// Create volume
await volumeManager.create(volume);
// Check volume was not created
expect(volume.create).to.not.have.been.called;
// Cleanup volumes
await Promise.all([
docker.getVolume(Volume.generateDockerName(111, 'main')).remove(),
]);
});
});
describe('Removing volumes', () => {
it('removes a volume if it exists', async () => {
// Setup volume data
await Promise.all([
docker.createVolume({
Name: Volume.generateDockerName(111, 'main'),
Labels: {
'io.balena.supervised': '1',
},
}),
]);
// Volume to remove
const volume = Volume.fromComposeObject('main', 111, 'deadbeef', {});
// Remove volume
await volumeManager.remove(volume);
// Check volume was removed
await expect(
docker.getVolume(Volume.generateDockerName(111, 'main')).inspect(),
).to.be.rejected;
});
it('does nothing on removal if the volume does not exist', async () => {
// Setup volume data
await Promise.all([
docker.createVolume({
Name: 'decoy-volume',
}),
]);
// Volume to remove
const volume = Volume.fromComposeObject('main', 111, 'deadbeef', {});
// Remove volume
await expect(volumeManager.remove(volume)).to.not.be.rejected;
// Cleanup volumes
await Promise.all([docker.getVolume('decoy-volume').remove()]);
});
});
describe('Removing orphaned volumes', () => {
it('removes any remaining unreferenced volumes after services have been deleted', async () => {
// Setup volume data
await Promise.all([
docker.createVolume({
Name: 'some-volume',
}),
// This volume is still referenced in the target state
docker.createVolume({
Name: Volume.generateDockerName(111, 'main'),
Labels: {
'io.balena.supervised': '1',
},
}),
docker.createVolume({
Name: Volume.generateDockerName(222, 'old'),
Labels: {
'io.balena.supervised': '1',
},
}),
// This volume is referenced by a container
docker.createVolume({
Name: 'other-volume',
}),
]);
// Create an empty image
await createDockerImage('hello', ['io.balena.testing=1'], docker);
// Create a container from the image
const { id: containerId } = await docker.createContainer({
Image: 'hello',
Cmd: ['true'],
HostConfig: {
Binds: ['other-volume:/data'],
},
});
await expect(
volumeManager.removeOrphanedVolumes([
// Keep any volumes in the target state
Volume.generateDockerName(111, 'main'),
]),
).to.not.be.rejected;
// All volumes should have been deleted
expect(await docker.listVolumes())
.to.have.property('Volumes')
.that.has.lengthOf(2);
// Reference volume should have been kept
await expect(
docker.getVolume(Volume.generateDockerName(111, 'main')).inspect(),
).to.not.be.rejected;
await expect(docker.getVolume('other-volume').inspect()).to.not.be
.rejected;
// Cleanup
await Promise.all([
docker.getVolume(Volume.generateDockerName(111, 'main')).remove(),
docker.getContainer(containerId).remove(),
]);
await Promise.all([
docker.getImage('hello').remove(),
docker.getVolume('other-volume').remove(),
]);
});
});
});

View File

@ -1,360 +0,0 @@
import { expect } from 'chai';
import * as sinon from 'sinon';
import {
createVolume,
createContainer,
withMockerode,
} from '~/test-lib/mockerode';
import * as volumeManager from '~/src/compose/volume-manager';
import log from '~/lib/supervisor-console';
import Volume from '~/src/compose/volume';
describe('compose/volume-manager', () => {
describe('Retrieving volumes from the engine', () => {
let logDebug: sinon.SinonStub;
before(() => {
logDebug = sinon.stub(log, 'debug');
});
after(() => {
logDebug.restore();
});
afterEach(() => {
logDebug.reset();
});
it('gets all supervised Volumes', async () => {
// Setup volume data
const volumeData = [
createVolume({
Name: Volume.generateDockerName(1, 'redis'),
// Recently created volumes contain io.balena.supervised label
Labels: { 'io.balena.supervised': '1' },
}),
createVolume({
Name: Volume.generateDockerName(1, 'mysql'),
// Recently created volumes contain io.balena.supervised label and app-uuid
Labels: {
'io.balena.supervised': '1',
'io.balena.app-uuid': 'deadbeef',
},
}),
createVolume({
Name: Volume.generateDockerName(1, 'backend'),
// Old Volumes will not have labels
}),
// Volume not created by the Supervisor
createVolume({ Name: 'user_created_volume' }),
createVolume({
Name: 'decoy',
// Added decoy to really test the inference (should not return)
Labels: { 'io.balena.supervised': '1' },
}),
];
// Perform test
await withMockerode(
async () => {
await expect(volumeManager.getAll()).to.eventually.deep.equal([
{
appId: 1,
appUuid: undefined,
config: {
driver: 'local',
driverOpts: {},
labels: {
'io.balena.supervised': '1',
},
},
name: 'redis',
},
{
appId: 1,
appUuid: 'deadbeef',
config: {
driver: 'local',
driverOpts: {},
labels: {
'io.balena.supervised': '1',
'io.balena.app-uuid': 'deadbeef',
},
},
name: 'mysql',
},
{
appId: 1,
appUuid: undefined,
config: {
driver: 'local',
driverOpts: {},
labels: {},
},
name: 'backend',
},
]);
// Check that debug message was logged saying we found a Volume not created by us
expect(logDebug.lastCall.lastArg).to.equal(
'Found unmanaged or anonymous Volume: decoy',
);
},
{ volumes: volumeData },
);
});
it('can parse null Volumes', async () => {
// Perform test with no volumes
await withMockerode(async () => {
await expect(volumeManager.getAll()).to.eventually.deep.equal([]);
});
});
it('gets the volume for specific application', async () => {
// Setup volume data
const volumes = [
createVolume({
Name: Volume.generateDockerName(111, 'app'),
Labels: {
'io.balena.supervised': '1',
},
}),
createVolume({
Name: Volume.generateDockerName(222, 'otherApp'),
Labels: {
'io.balena.supervised': '1',
},
}),
];
// Perform test
await withMockerode(
async () => {
await expect(
volumeManager.getAllByAppId(111),
).to.eventually.deep.equal([
{
appId: 111,
appUuid: undefined,
config: {
driver: 'local',
driverOpts: {},
labels: {
'io.balena.supervised': '1',
},
},
name: 'app',
},
]);
},
{ volumes },
);
});
});
describe('Creating volumes', () => {
it('creates a volume if it does not exist', async () => {
// Perform test
await withMockerode(async (mockerode) => {
// The volume does not exist on the engine before
expect(
mockerode.getVolume(Volume.generateDockerName(111, 'main')).inspect(),
).to.be.rejected;
// Volume to create
const volume = Volume.fromComposeObject('main', 111, 'deadbeef', {});
sinon.spy(volume, 'create');
// Create volume
await volumeManager.create(volume);
// Check that the creation function was called
expect(volume.create).to.have.been.calledOnce;
});
});
it('does not try to create a volume that already exists', async () => {
// Setup volume data
const volumes = [
createVolume({
Name: Volume.generateDockerName(111, 'main'),
Labels: {
'io.balena.supervised': '1',
},
}),
];
// Perform test
await withMockerode(
async () => {
// Create compose object for volume already set up in mock engine
const volume = Volume.fromComposeObject('main', 111, 'deadbeef', {});
sinon.spy(volume, 'create');
// Create volume
await volumeManager.create(volume);
// Check volume was not created
expect(volume.create).to.not.have.been.called;
},
{ volumes },
);
});
});
describe('Removing volumes', () => {
it('removes a volume if it exists', async () => {
// Setup volume data
const volumes = [
createVolume({
Name: Volume.generateDockerName(111, 'main'),
Labels: {
'io.balena.supervised': '1',
},
}),
];
// Perform test
await withMockerode(
async (mockerode) => {
// Volume to remove
const volume = Volume.fromComposeObject('main', 111, 'deadbeef', {});
sinon.spy(volume, 'remove');
// Remove volume
await volumeManager.remove(volume);
// Check volume was removed
expect(volume.remove).to.be.calledOnce;
expect(mockerode.removeVolume).to.have.been.calledOnceWith(
Volume.generateDockerName(111, 'main'),
);
},
{ volumes },
);
});
it('does nothing on removal if the volume does not exist', async () => {
// Setup volume data
const volumes = [
createVolume({
Name: 'decoy-volume',
}),
];
// Perform test
await withMockerode(
async (mockerode) => {
// Volume to remove
const volume = Volume.fromComposeObject('main', 111, 'deadbeef', {});
sinon.spy(volume, 'remove');
// Remove volume
await expect(volumeManager.remove(volume)).to.not.be.rejected;
expect(mockerode.removeVolume).to.not.have.been.called;
},
{ volumes },
);
});
});
describe('Removing orphaned volumes', () => {
it('removes any remaining unreferenced volumes after services have been deleted', async () => {
// Setup volume data
const volumes = [
createVolume({
Name: 'some-volume',
}),
createVolume({
Name: Volume.generateDockerName(111, 'main'),
Labels: {
'io.balena.supervised': '1',
},
}),
];
await withMockerode(
async (mockerode) => {
await volumeManager.removeOrphanedVolumes([]);
expect(mockerode.removeVolume).to.have.been.calledTwice;
expect(mockerode.removeVolume).to.have.been.calledWith('some-volume');
expect(mockerode.removeVolume).to.have.been.calledWith(
Volume.generateDockerName(111, 'main'),
);
},
{ volumes },
);
});
it('keeps volumes still referenced in target state', async () => {
// Setup volume data
const volumes = [
createVolume({
Name: 'some-volume',
}),
createVolume({
Name: Volume.generateDockerName(111, 'main'),
Labels: {
'io.balena.supervised': '1',
},
}),
createVolume({
Name: Volume.generateDockerName(222, 'old'),
Labels: {
'io.balena.supervised': '1',
},
}),
];
await withMockerode(
async (mockerode) => {
await volumeManager.removeOrphanedVolumes([
Volume.generateDockerName(111, 'main'),
]);
expect(mockerode.removeVolume).to.have.been.calledTwice;
expect(mockerode.removeVolume).to.have.been.calledWith('some-volume');
expect(mockerode.removeVolume).to.have.been.calledWith(
Volume.generateDockerName(222, 'old'),
);
},
{ volumes },
);
});
it('keeps volumes still referenced by a container', async () => {
// Setup volume data
const volumes = [
createVolume({
Name: 'some-volume',
}),
createVolume({
Name: Volume.generateDockerName(111, 'main'),
Labels: {
'io.balena.supervised': '1',
},
}),
];
const containers = [
createContainer({
Id: 'some-service',
Mounts: [
{
Name: 'some-volume',
},
],
}),
];
await withMockerode(
async (mockerode) => {
await volumeManager.removeOrphanedVolumes([]);
// Container that has a volume should not be removed
expect(mockerode.removeVolume).to.have.been.calledOnceWith(
Volume.generateDockerName(111, 'main'),
);
},
{ volumes, containers },
);
});
});
});

28
test/lib/docker-helper.ts Normal file
View File

@ -0,0 +1,28 @@
import * as Docker from 'dockerode';
import * as tar from 'tar-stream';
// Creates an image from scratch with just some labels
export async function createDockerImage(
name: string,
labels: [string, ...string[]],
docker = new Docker(),
) {
const pack = tar.pack(); // pack is a streams2 stream
pack.entry(
{ name: 'Dockerfile' },
['FROM scratch'].concat(labels.map((l) => `LABEL ${l}`)).join('\n'),
(err) => {
if (err) {
throw err;
}
pack.finalize();
},
);
// Create an empty image
const stream = await docker.buildImage(pack, { t: name });
return await new Promise((resolve, reject) => {
docker.modem.followProgress(stream, (err: any, res: any) =>
err ? reject(err) : resolve(res),
);
});
}