balena-supervisor/test/lib/mockerode.ts
Felipe Lalanne f1bd4b8d9b Use tags to track supervised images in docker
The image manager module now uses tags instead of docker IDs as the main
way to identify docker images on the engine. That is, if the target
state image has a name `imageName:tag@digest`, the supervisor will always use
the given `imageName` and `tag` (which may be empty) to tag the image on
the engine after fetching. This PR also adds checkups to ensure
consistency is maintained between the database and the engine.

Using tags allows to simplify query and removal operations, since now
removing the image now means removing tags matching the image name.

Before this change the supervisor relied only on information in the
supervisor database, and used that to remove images by docker ID. However, the docker
id is not a reliable identifier, since images retain the same id between
releases or between services in the same release.

List of squashed commits
- Remove custom type NormalizedImageInfo
- Remove dependency on docker-toolbelt
- Use tags to traack supervised images in docker
- Ensure tag removal occurs in sequence
- Only save database image after download confirmed

Relates-to: #1616 #1579
Change-type: patch
2021-07-26 09:52:25 -04:00

979 lines
23 KiB
TypeScript

import * as dockerode from 'dockerode';
import * as sinon from 'sinon';
// Recursively convert properties of an object as optional
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends Array<infer U>
? Array<DeepPartial<U>>
: T[P] extends object
? DeepPartial<T[P]>
: T[P];
};
// Partial container inspect info for receiving as testing data
export type PartialContainerInspectInfo = DeepPartial<
dockerode.ContainerInspectInfo
> & {
Id: string;
};
export type PartialNetworkInspectInfo = DeepPartial<
dockerode.NetworkInspectInfo
> & {
Id: string;
};
export type PartialVolumeInspectInfo = DeepPartial<
dockerode.VolumeInspectInfo
> & {
Name: string;
};
export type PartialImageInspectInfo = DeepPartial<
dockerode.ImageInspectInfo
> & {
Id: string;
};
type Methods<T> = {
[K in keyof T]: T[K] extends (...args: any) => any ? T[K] : never;
};
function createFake<Prototype extends object>(prototype: Prototype) {
return (Object.getOwnPropertyNames(prototype) as Array<keyof Prototype>)
.filter((fn) => fn === 'constructor' || typeof prototype[fn] === 'function')
.reduce(
(res, fn) => ({
...res,
[fn]: () => {
throw Error(
`Fake method not implemented: ${prototype.constructor.name}.${fn}()`,
);
},
}),
{} as Methods<Prototype>,
);
}
export function createNetwork(network: PartialNetworkInspectInfo) {
const { Id, ...networkInspect } = network;
const inspectInfo = {
Id,
Name: 'default',
Created: '2015-01-06T15:47:31.485331387Z',
Scope: 'local',
Driver: 'bridge',
EnableIPv6: false,
Internal: false,
Attachable: true,
Ingress: false,
IPAM: {
Driver: 'default',
Options: {},
Config: [
{
Subnet: '172.18.0.0/16',
Gateway: '172.18.0.1',
},
],
},
Containers: {},
Options: {},
Labels: {},
// Add defaults
...networkInspect,
};
const fakeNetwork = createFake(dockerode.Network.prototype);
return {
...fakeNetwork, // by default all methods fail unless overriden
id: Id,
inspectInfo,
inspect: () => Promise.resolve(inspectInfo),
remove: (): Promise<boolean> =>
Promise.reject('Mock network not attached to an engine'),
};
}
export type MockNetwork = ReturnType<typeof createNetwork>;
export function createContainer(container: PartialContainerInspectInfo) {
const createContainerInspectInfo = (
partial: PartialContainerInspectInfo,
): dockerode.ContainerInspectInfo => {
const {
Id,
State,
Config,
NetworkSettings,
HostConfig,
Mounts,
...ContainerInfo
} = partial;
return {
Id,
Created: '2015-01-06T15:47:31.485331387Z',
Path: '/usr/bin/sleep',
Args: ['infinity'],
State: {
Status: 'running',
ExitCode: 0,
Running: true,
Paused: false,
Restarting: false,
OOMKilled: false,
...State, // User passed options
},
Image: 'deadbeef',
Name: 'main',
HostConfig: {
AutoRemove: false,
Binds: [],
LogConfig: {
Type: 'journald',
Config: {},
},
NetworkMode: 'bridge',
PortBindings: {},
RestartPolicy: {
Name: 'always',
MaximumRetryCount: 0,
},
VolumeDriver: '',
CapAdd: [],
CapDrop: [],
Dns: [],
DnsOptions: [],
DnsSearch: [],
ExtraHosts: [],
GroupAdd: [],
IpcMode: 'shareable',
Privileged: false,
SecurityOpt: [],
ShmSize: 67108864,
Memory: 0,
MemoryReservation: 0,
OomKillDisable: false,
Devices: [],
Ulimits: [],
...HostConfig, // User passed options
},
Config: {
Hostname: Id,
Labels: {},
Cmd: ['/usr/bin/sleep', 'infinity'],
Env: [] as string[],
Volumes: {},
Image: 'alpine:latest',
...Config, // User passed options
},
NetworkSettings: {
Networks: {
default: {
Aliases: [],
Gateway: '172.18.0.1',
IPAddress: '172.18.0.2',
IPPrefixLen: 16,
MacAddress: '00:00:de:ad:be:ef',
},
},
...NetworkSettings, // User passed options
},
Mounts: [
...(Mounts || []).map(({ Name, ...opts }) => ({
Name,
Type: 'volume',
Source: `/var/lib/docker/volumes/${Name}/_data`,
Destination: '/opt/${Name}/path',
Driver: 'local',
Mode: '',
RW: true,
Propagation: '',
// Replace defaults
...opts,
})),
],
...ContainerInfo,
} as dockerode.ContainerInspectInfo;
};
const createContainerInfo = (
containerInspectInfo: dockerode.ContainerInspectInfo,
): dockerode.ContainerInfo => {
const {
Id,
Name,
Created,
Image,
State,
HostConfig,
Config,
Mounts,
NetworkSettings,
} = containerInspectInfo;
const capitalizeFirst = (s: string) =>
s.charAt(0).toUpperCase() + s.slice(1);
// Calculate summary from existing inspectInfo object
return {
Id,
Names: [Name],
ImageID: Image,
Image: Config.Image,
Created: Date.parse(Created),
Command: Config.Cmd.join(' '),
State: capitalizeFirst(State.Status),
Status: `Exit ${State.ExitCode}`,
HostConfig: {
NetworkMode: HostConfig.NetworkMode!,
},
Ports: [],
Labels: Config.Labels,
NetworkSettings: {
Networks: NetworkSettings.Networks,
},
Mounts: Mounts as dockerode.ContainerInfo['Mounts'],
};
};
const inspectInfo = createContainerInspectInfo(container);
const info = createContainerInfo(inspectInfo);
const { Id: id } = inspectInfo;
const fakeContainer = createFake(dockerode.Container.prototype);
return {
...fakeContainer, // by default all methods fail unless overriden
id,
inspectInfo,
info,
inspect: () => Promise.resolve(inspectInfo),
remove: (): Promise<boolean> =>
Promise.reject('Mock container not attached to an engine'),
};
}
export type MockContainer = ReturnType<typeof createContainer>;
interface Reference {
repository: string;
tag?: string;
digest?: string;
toString: () => string;
}
const parseReference = (uri: string): Reference => {
// https://github.com/docker/distribution/blob/release/2.7/reference/normalize.go#L62
// https://github.com/docker/distribution/blob/release/2.7/reference/regexp.go#L44
const match = uri.match(
/^(?:(localhost|.*?[.:].*?)\/)?(.+?)(?::(.*?))?(?:@(.*?))?$/,
);
if (!match) {
throw new Error(`Could not parse the image: ${uri}`);
}
const [, registry, imageName, tagName, digest] = match;
let tag = tagName;
if (!digest && !tag) {
tag = 'latest';
}
const digestMatch = digest?.match(
/^[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[0-9a-f-A-F]{32,}$/,
);
if (!imageName || (digest && !digestMatch)) {
throw new Error(
'Invalid image name, expected [domain.tld/]repo/image[:tag][@digest] format',
);
}
const repository = [registry, imageName].filter((s) => !!s).join('/');
return {
repository,
tag,
digest,
toString: () =>
repository +
(tagName ? `:${tagName}` : '') +
(digest ? `@${digest}` : ''),
};
};
export function createImage(
// Do not allow RepoTags or RepoDigests to be provided.
// References must be used instead
image: Omit<PartialImageInspectInfo, 'RepoTags' | 'RepoDigests'>,
{ References = [] as string[] } = {},
) {
const createImageInspectInfo = (
partialImage: PartialImageInspectInfo,
): dockerode.ImageInspectInfo => {
const {
Id,
ContainerConfig,
Config,
GraphDriver,
RootFS,
...Info
} = partialImage;
return {
Id,
RepoTags: [],
RepoDigests: [
'registry2.resin.io/v2/8ddbe4a22e881f06def0f31400bfb6de@sha256:09b0db9e71cead5f91107fc9254b1af7088444cc6da55afa2da595940f72a34a',
],
Parent: '',
Comment: 'Not a real image',
Created: '2018-08-15T12:43:06.43392045Z',
Container:
'b6cc9227f272b905512a58926b6d515b38de34b604386031aa3c21e94d9dbb4a',
ContainerConfig: {
Hostname: 'f15babe8256c',
Domainname: '',
User: '',
AttachStdin: false,
AttachStdout: false,
AttachStderr: false,
Tty: false,
OpenStdin: false,
StdinOnce: false,
Env: [
'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
],
Cmd: ['/usr/bin/sleep', 'infinity'],
ArgsEscaped: true,
Image:
'sha256:828d725f5e6d09ee9abc214f6c11fadf69192ba4871b050984cc9c4cec37b208',
Volumes: {},
WorkingDir: '',
Entrypoint: null,
OnBuild: null,
Labels: {},
...ContainerConfig,
} as dockerode.ImageInspectInfo['ContainerConfig'],
DockerVersion: '17.05.0-ce',
Author: '',
Config: {
Hostname: '',
Domainname: '',
User: '',
AttachStdin: false,
AttachStdout: false,
AttachStderr: false,
Tty: false,
OpenStdin: false,
StdinOnce: false,
Env: [
'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
],
Cmd: ['/usr/bin/sleep', 'infinity'],
ArgsEscaped: true,
Image:
'sha256:828d725f5e6d09ee9abc214f6c11fadf69192ba4871b050984cc9c4cec37b208',
Volumes: {},
WorkingDir: '/usr/src/app',
Entrypoint: ['/usr/bin/entry.sh'],
OnBuild: [],
Labels: {
...(Config?.Labels ?? {}),
},
...Config,
} as dockerode.ImageInspectInfo['Config'],
Architecture: 'arm64',
Os: 'linux',
Size: 38692178,
VirtualSize: 38692178,
GraphDriver: {
Data: {
DeviceId: 'deadbeef',
DeviceName: 'dummy',
DeviceSize: '10m',
},
Name: 'aufs',
...GraphDriver,
} as dockerode.ImageInspectInfo['GraphDriver'],
RootFS: {
Type: 'layers',
Layers: [
'sha256:7c30ac6ce381873d5388b7d23b346af7d1e5f6af000a84b97e6203ed9e6dcab2',
'sha256:450b73019ae79e6a99774fcd37c18769f95065c8b271be936dfb3f93afadc4a8',
'sha256:6ab67aaf666bfb7001ab93deffe785f24775f4e0da3d6d421ad6096ba869fd0d',
],
...RootFS,
} as dockerode.ImageInspectInfo['RootFS'],
...Info,
};
};
const createImageInfo = (imageInspectInfo: dockerode.ImageInspectInfo) => {
const {
Id,
Parent: ParentId,
RepoTags,
RepoDigests,
Config,
} = imageInspectInfo;
const { Labels } = Config;
return {
Id,
ParentId,
RepoTags,
RepoDigests,
Created: 1474925151,
Size: 103579269,
VirtualSize: 103579269,
SharedSize: 0,
Labels,
Containers: 0,
};
};
const references = References.map((uri) => parseReference(uri));
// Generate image repo tags and digests for inspect
const { tags, digests } = references.reduce(
(pairs, ref) => {
if (ref.tag) {
pairs.tags.push([ref.repository, ref.tag].join(':'));
}
if (ref.digest) {
pairs.digests.push([ref.repository, ref.digest].join('@'));
}
return pairs;
},
{ tags: [] as string[], digests: [] as string[] },
);
const inspectInfo = createImageInspectInfo({
...image,
// Use references to fill RepoTags and RepoDigests ignoring
// those from the partial inspect info
RepoTags: [...new Set([...tags])],
RepoDigests: [...new Set([...digests])],
});
const info = createImageInfo(inspectInfo);
const { Id: id } = inspectInfo;
const fakeImage = createFake(dockerode.Image.prototype);
return {
...fakeImage, // by default all methods fail unless overriden
id,
info,
inspectInfo,
references,
inspect: () => Promise.resolve(inspectInfo),
remove: (_opts: any): Promise<boolean> =>
Promise.reject('Mock image not attached to an engine'),
};
}
export type MockImage = ReturnType<typeof createImage>;
export function createVolume(volume: PartialVolumeInspectInfo) {
const { Name, Labels, ...partialVolumeInfo } = volume;
const inspectInfo: dockerode.VolumeInspectInfo = {
Name,
Driver: 'local',
Mountpoint: '/var/lib/docker/volumes/resin-data',
Labels: {
...Labels,
} as dockerode.VolumeInspectInfo['Labels'],
Scope: 'local',
Options: {},
...partialVolumeInfo,
};
const fakeVolume = createFake(dockerode.Volume.prototype);
return {
...fakeVolume, // by default all methods fail unless overriden
name: Name,
inspectInfo,
inspect: () => Promise.resolve(inspectInfo),
remove: (): Promise<boolean> =>
Promise.reject('Mock volume not attached to an engine'),
};
}
export type MockVolume = ReturnType<typeof createVolume>;
export type MockEngineState = {
containers?: MockContainer[];
networks?: MockNetwork[];
volumes?: MockVolume[];
images?: MockImage[];
};
// Good enough function go generate ids for mock engine
// source: https://stackoverflow.com/a/2117523
function uuidv4() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
// tslint:disable
const r = (Math.random() * 16) | 0,
v = c == 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
type Stubs<T> = {
[K in keyof T]: T[K] extends (...args: infer TArgs) => infer TReturnValue
? sinon.SinonStub<TArgs, TReturnValue>
: never;
};
export class MockEngine {
networks: Dictionary<MockNetwork> = {};
containers: Dictionary<MockContainer> = {};
images: Dictionary<MockImage> = {};
volumes: Dictionary<MockVolume> = {};
constructor({
networks = [],
containers = [],
images = [],
volumes = [],
}: MockEngineState) {
// Key networks by id
this.networks = networks.reduce((networkMap, network) => {
const { id } = network;
return {
...networkMap,
[id]: { ...network, remove: () => this.removeNetwork(id) },
};
}, {});
this.containers = containers.reduce((containerMap, container) => {
const { id } = container;
return {
...containerMap,
[id]: {
...container,
remove: () => this.removeContainer(id),
},
};
}, {});
this.images = images.reduce((imageMap, image) => {
const { id } = image;
return {
...imageMap,
[id]: {
...image,
remove: (options: any) => this.removeImage(id, options),
},
};
}, {});
this.volumes = volumes.reduce((volumeMap, volume) => {
const { name } = volume;
return {
...volumeMap,
[name]: {
...volume,
remove: () => this.removeVolume(name),
},
};
}, {});
}
getNetwork(id: string) {
const network = Object.values(this.networks).find(
(n) => n.inspectInfo.Id === id || n.inspectInfo.Name === id,
);
if (!network) {
return {
id,
inspect: () =>
Promise.reject({ statusCode: 404, message: `No such network ${id}` }),
remove: () =>
Promise.reject({ statusCode: 404, message: `No such network ${id}` }),
} as MockNetwork;
}
return network;
}
listNetworks() {
return Promise.resolve(
Object.values(this.networks).map((network) => network.inspectInfo),
);
}
removeNetwork(id: string) {
const network = Object.values(this.networks).find(
(n) => n.inspectInfo.Id === id || n.inspectInfo.Name === id,
);
// Throw an error if the network does not exist
// this should never happen
if (!network) {
return Promise.reject({
statusCode: 404,
message: `No such network ${id}`,
});
}
return Promise.resolve(delete this.networks[network.id]);
}
createNetwork(options: dockerode.NetworkCreateOptions) {
const Id = uuidv4();
const network = createNetwork({ Id, ...options });
// Add to the list
this.networks[Id] = network;
}
listContainers() {
return Promise.resolve(
// List containers returns ContainerInfo objects so we return summaries
Object.values(this.containers).map((container) => container.info),
);
}
createContainer(options: dockerode.ContainerCreateOptions) {
const Id = uuidv4();
const { name: Name, HostConfig, NetworkingConfig, ...Config } = options;
const container = createContainer({
Id,
Name,
HostConfig,
Config,
NetworkSettings: { Networks: NetworkingConfig?.EndpointsConfig ?? {} },
State: { Status: 'created' },
});
// Add to the list
this.containers[Id] = {
...container,
remove: () => this.removeContainer(Id),
};
}
getContainer(id: string) {
const container = Object.values(this.containers).find(
(c) => c.inspectInfo.Id === id || c.inspectInfo.Name === id,
);
if (!container) {
return {
id,
inspect: () =>
Promise.reject({
statusCode: 404,
message: `No such container ${id}`,
}),
} as MockContainer;
}
return { ...container, remove: () => this.removeContainer(id) };
}
removeContainer(id: string) {
const container = Object.values(this.containers).find(
(c) => c.inspectInfo.Id === id || c.inspectInfo.Name === id,
);
// Throw an error if the container does not exist
// this should never happen
if (!container) {
return Promise.reject({
statusCode: 404,
message: `No such container ${id}`,
});
}
return Promise.resolve(delete this.containers[container.id]);
}
listImages({ filters = { reference: [] as string[] } } = {}) {
const filterList = [] as ((img: MockImage) => boolean)[];
const transformers = [] as ((img: MockImage) => MockImage)[];
// Add reference filters
if (filters.reference?.length > 0) {
const isMatchingReference = ({ repository, tag }: Reference) =>
filters.reference.includes(repository) ||
filters.reference.includes(
[repository, tag].filter((s) => !!s).join(':'),
);
// Create a filter for images matching the reference
filterList.push((img) => img.references.some(isMatchingReference));
// Create a transformer removing unused references from the image
transformers.push((img) =>
createImage(img.inspectInfo, {
References: img.references
.filter(isMatchingReference)
.map((ref) => ref.toString()),
}),
);
}
return Promise.resolve(
Object.values(this.images)
// Remove images that do not match the filter
.filter((img) => filterList.every((fn) => fn(img)))
// Transform the image if needed
.map((image) => transformers.reduce((img, next) => next(img), image))
.map((image) => image.info),
);
}
getVolume(name: string) {
const volume = Object.values(this.volumes).find((v) => v.name === name);
if (!volume) {
return {
name,
inspect: () =>
Promise.reject({
statusCode: 404,
message: `No such volume ${name}`,
}),
remove: () =>
Promise.reject({
statusCode: 404,
message: `No such volume ${name}`,
}),
};
}
return volume;
}
listVolumes() {
return Promise.resolve({
Volumes: Object.values(this.volumes).map((volume) => volume.inspectInfo),
Warnings: [] as string[],
});
}
removeVolume(name: string) {
return Promise.resolve(delete this.volumes[name]);
}
createVolume(options: PartialVolumeInspectInfo) {
const { Name } = options;
const volume = createVolume(options);
// Add to the list
this.volumes[Name] = volume;
}
// NOT a dockerode method
// Implements this https://docs.docker.com/engine/reference/commandline/rmi/
// Removes (and un-tags) one or more images from the host node.
// If an image has multiple tags, using this command with the tag as
// a parameter only removes the tag. If the tag is the only one for the image,
// both the image and the tag are removed.
// Does not remove images from a registry.
// You cannot remove an image of a running container unless you use the
// -f option. To see all images on a host use the docker image ls command.
//
// You can remove an image using its short or long ID, its tag, or its digest.
// If an image has one or more tags referencing it, you must remove all of them
// before the image is removed. Digest references are removed automatically when
// an image is removed by tag.
removeImage(name: string, { force } = { force: false }) {
const image = this.findImage(name);
// Throw an error if the image does not exist
if (!image) {
return Promise.reject({
statusCode: 404,
message: `No such image ${name}`,
});
}
// Get the id of the iamge
const { Id: id } = image.inspectInfo;
// If the given identifier is an id
if (id === name) {
if (!force && image.references.length > 1) {
// If the name is an id and there are multiple tags
// or digests referencing the image, don't delete unless the force option
return Promise.reject({
statusCode: 409,
message: `Unable to delete image ${name} with multiple references`,
});
}
return Promise.resolve(delete this.images[id]);
}
// If the name is not an id, then it must be a reference
const ref = parseReference(name);
const References = image.references
.filter(
(r) =>
r.repository !== ref.repository ||
(r.digest !== ref.digest && r.tag !== ref.tag),
)
.map((r) => r.toString());
if (References.length > 0) {
// If there are still digests or tags, just update the stored image
this.images[id] = {
...createImage(image.inspectInfo, { References }),
remove: (options: any) => this.removeImage(id, options),
};
return Promise.resolve(true);
}
// Remove the original image
return Promise.resolve(delete this.images[id]);
}
private findImage(name: string) {
if (this.images[name]) {
return this.images[name];
}
// If the identifier is not an id it must be a reference
const ref = parseReference(name);
return Object.values(this.images).find((img) =>
img.references.some(
(r) =>
// Find an image that has a reference with matching digest or tag
r.repository === ref.repository &&
(r.digest === ref.digest || r.tag === ref.tag),
),
);
}
getImage(name: string) {
const image = this.findImage(name);
// If the image doesn't exist return an empty object
if (!image) {
return {
id: name,
inspect: () =>
Promise.reject({ statusCode: 404, message: `No such image ${name}` }),
remove: () =>
Promise.reject({ statusCode: 404, message: `No such image ${name}` }),
};
}
return {
...image,
remove: (options: any) => this.removeImage(name, options),
};
}
getEvents() {
console.log(`Calling mockerode.getEvents 🐳`);
return Promise.resolve({ on: () => void 0, pipe: () => void 0 });
}
}
export function createMockerode(engine: MockEngine) {
const dockerodeStubs: Stubs<dockerode> = (Object.getOwnPropertyNames(
dockerode.prototype,
) as (keyof dockerode)[])
.filter((fn) => typeof dockerode.prototype[fn] === 'function')
.reduce((stubMap, fn) => {
const stub = sinon.stub(dockerode.prototype, fn);
if (MockEngine.prototype.hasOwnProperty(fn)) {
stub.callsFake((MockEngine.prototype as any)[fn].bind(engine));
} else {
// By default all unimplemented methods will throw to avoid the tests
// silently failing
stub.throws(`Not implemented: Dockerode.${fn}`);
}
return { ...stubMap, [fn]: stub };
}, {} as Stubs<dockerode>);
const { removeImage, removeNetwork, removeVolume, removeContainer } = engine;
// Add stubs to additional engine methods we want to
// be able to check
const mockEngineStubs = {
removeImage: sinon
.stub(engine, 'removeImage')
.callsFake(removeImage.bind(engine)),
removeNetwork: sinon
.stub(engine, 'removeNetwork')
.callsFake(removeNetwork.bind(engine)),
removeVolume: sinon
.stub(engine, 'removeVolume')
.callsFake(removeVolume.bind(engine)),
removeContainer: sinon
.stub(engine, 'removeContainer')
.callsFake(removeContainer.bind(engine)),
};
return {
...dockerodeStubs,
...mockEngineStubs,
restore: () => {
Object.values(dockerodeStubs).forEach((stub) => stub.restore());
Object.values(mockEngineStubs).forEach((spy) => spy.restore());
},
resetHistory: () => {
Object.values(dockerodeStubs).forEach((stub) => stub.resetHistory());
Object.values(mockEngineStubs).forEach((spy) => spy.resetHistory());
},
};
}
export type Mockerode = ReturnType<typeof createMockerode>;
export async function withMockerode(
test: (dockerode: Mockerode) => Promise<any>,
initialState: MockEngineState = {
containers: [],
networks: [],
volumes: [],
images: [],
},
) {
const mockEngine = new MockEngine(initialState);
const mockerode = createMockerode(mockEngine);
try {
// run the tests
await test(mockerode);
} finally {
// restore stubs always
mockerode.restore();
}
}