Skip to content

Commit 4b470b5

Browse files
Add auto cleanup control for containers and compose (#1293)
1 parent 74b2453 commit 4b470b5

File tree

7 files changed

+258
-3
lines changed

7 files changed

+258
-3
lines changed

docs/features/compose.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,19 @@ const environment = await new DockerComposeEnvironment(composeFilePath, composeF
144144
.up();
145145
```
146146

147+
### With auto cleanup disabled
148+
149+
By default Testcontainers registers the compose project with Ryuk so the stack is torn down automatically when the process exits. You can disable that registration for a specific compose stack:
150+
151+
```js
152+
const environment = await new DockerComposeEnvironment(composeFilePath, composeFile)
153+
.withProjectName("test")
154+
.withAutoCleanup(false)
155+
.up();
156+
```
157+
158+
This only disables automatic cleanup. Explicit calls to `.down()`, `.stop()`, or `await using` disposal still tear the stack down as usual.
159+
147160
### With custom client options
148161

149162
See [docker-compose](https://github.com/PDMLab/docker-compose/) library.

docs/features/containers.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,16 @@ const container = await new GenericContainer("alpine")
400400
await container.stop({ remove: true }); // The container is stopped *AND* removed
401401
```
402402

403+
You can also disable automatic Ryuk cleanup for a specific container while leaving it enabled for the rest of the test session:
404+
405+
```js
406+
const container = await new GenericContainer("alpine")
407+
.withAutoCleanup(false)
408+
.start();
409+
```
410+
411+
This only affects automatic cleanup when the process exits unexpectedly or the container is otherwise left running. Explicit calls to `.stop()` still use the normal stop and removal behavior, so combine this with `.withAutoRemove(false)` or `.stop({ remove: false })` if you also want explicit stops to keep the container.
412+
403413
Keep in mind that disabling ryuk (set `TESTCONTAINERS_RYUK_DISABLED` to `true`) **and** disabling automatic removal of containers will make containers persist after you're done working with them.
404414

405415

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { ContainerInfo } from "dockerode";
2+
import { ContainerRuntimeClient } from "../container-runtime";
3+
import { DockerComposeEnvironment } from "./docker-compose-environment";
4+
5+
let mockClient: ContainerRuntimeClient;
6+
let mockGetReaper = vi.fn();
7+
8+
vi.mock("../container-runtime", async () => ({
9+
...(await vi.importActual("../container-runtime")),
10+
getContainerRuntimeClient: vi.fn(async () => mockClient),
11+
}));
12+
13+
vi.mock("../reaper/reaper", async () => ({
14+
...(await vi.importActual("../reaper/reaper")),
15+
getReaper: vi.fn(async (client: ContainerRuntimeClient) => await mockGetReaper(client)),
16+
}));
17+
18+
describe.sequential("DockerComposeEnvironment auto cleanup", () => {
19+
beforeEach(() => {
20+
vi.clearAllMocks();
21+
mockGetReaper = vi.fn();
22+
});
23+
24+
it("should register the compose project with the reaper by default", async () => {
25+
mockClient = createMockContainerRuntimeClient();
26+
const addComposeProject = vi.fn();
27+
mockGetReaper.mockResolvedValue({
28+
sessionId: "session-id",
29+
containerId: "reaper-container-id",
30+
addComposeProject,
31+
addSession: vi.fn(),
32+
});
33+
34+
await new DockerComposeEnvironment("/tmp", "docker-compose.yml").withProjectName("my-project").up();
35+
36+
expect(mockGetReaper).toHaveBeenCalledWith(mockClient);
37+
expect(addComposeProject).toHaveBeenCalledWith("my-project");
38+
});
39+
40+
it("should not register the compose project with the reaper when auto cleanup is disabled", async () => {
41+
mockClient = createMockContainerRuntimeClient();
42+
43+
await new DockerComposeEnvironment("/tmp", "docker-compose.yml")
44+
.withProjectName("my-project")
45+
.withAutoCleanup(false)
46+
.up();
47+
48+
expect(mockGetReaper).not.toHaveBeenCalled();
49+
});
50+
});
51+
52+
function createMockContainerRuntimeClient(): ContainerRuntimeClient {
53+
return {
54+
compose: {
55+
down: vi.fn(),
56+
pull: vi.fn(),
57+
stop: vi.fn(),
58+
up: vi.fn(),
59+
},
60+
container: {
61+
list: vi.fn(async () => [] as ContainerInfo[]),
62+
},
63+
image: {},
64+
info: {
65+
containerRuntime: {
66+
host: "localhost",
67+
hostIps: [{ address: "127.0.0.1", family: 4 }],
68+
},
69+
node: {
70+
architecture: "x64",
71+
platform: "linux",
72+
version: process.version,
73+
},
74+
},
75+
network: {},
76+
} as unknown as ContainerRuntimeClient;
77+
}

packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export class DockerComposeEnvironment {
1818

1919
private projectName: string;
2020
private build = false;
21+
private autoCleanup = true;
2122
private recreate = true;
2223
private environmentFile = "";
2324
private profiles: string[] = [];
@@ -39,6 +40,11 @@ export class DockerComposeEnvironment {
3940
return this;
4041
}
4142

43+
public withAutoCleanup(autoCleanup: boolean): this {
44+
this.autoCleanup = autoCleanup;
45+
return this;
46+
}
47+
4248
public withEnvironment(environment: Environment): this {
4349
this.environment = { ...this.environment, ...environment };
4450
return this;
@@ -95,8 +101,10 @@ export class DockerComposeEnvironment {
95101
public async up(services?: Array<string>): Promise<StartedDockerComposeEnvironment> {
96102
log.info(`Starting DockerCompose environment "${this.projectName}"...`);
97103
const client = await getContainerRuntimeClient();
98-
const reaper = await getReaper(client);
99-
reaper.addComposeProject(this.projectName);
104+
if (this.autoCleanup) {
105+
const reaper = await getReaper(client);
106+
reaper.addComposeProject(this.projectName);
107+
}
100108

101109
const {
102110
composeOptions: clientComposeOptions = [],
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { Container, ContainerCreateOptions, ContainerInspectInfo } from "dockerode";
2+
import { Readable } from "stream";
3+
import { ContainerRuntimeClient } from "../container-runtime";
4+
import { LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels";
5+
import { WaitStrategy } from "../wait-strategies/wait-strategy";
6+
import { GenericContainer } from "./generic-container";
7+
8+
let mockClient: ContainerRuntimeClient;
9+
let mockGetReaper = vi.fn();
10+
11+
vi.mock("../container-runtime", async () => ({
12+
...(await vi.importActual("../container-runtime")),
13+
getContainerRuntimeClient: vi.fn(async () => mockClient),
14+
}));
15+
16+
vi.mock("../reaper/reaper", async () => ({
17+
...(await vi.importActual("../reaper/reaper")),
18+
getReaper: vi.fn(async (client: ContainerRuntimeClient) => await mockGetReaper(client)),
19+
}));
20+
21+
describe.sequential("GenericContainer auto cleanup", () => {
22+
beforeEach(() => {
23+
vi.clearAllMocks();
24+
mockGetReaper = vi.fn();
25+
});
26+
27+
it("should register the container with the reaper by default", async () => {
28+
const { client, inspectResult } = createMockContainerRuntimeClient();
29+
mockClient = client;
30+
mockGetReaper.mockResolvedValue({
31+
sessionId: "session-id",
32+
containerId: "reaper-container-id",
33+
addComposeProject: vi.fn(),
34+
addSession: vi.fn(),
35+
});
36+
37+
const container = await new GenericContainer("alpine").withWaitStrategy(createNoopWaitStrategy()).start();
38+
39+
expect(mockGetReaper).toHaveBeenCalledWith(client);
40+
expect(container.getLabels()[LABEL_TESTCONTAINERS_SESSION_ID]).toBe("session-id");
41+
expect(inspectResult.Config.Labels[LABEL_TESTCONTAINERS_SESSION_ID]).toBe("session-id");
42+
});
43+
44+
it("should not register the container with the reaper when auto cleanup is disabled", async () => {
45+
const { client, inspectResult } = createMockContainerRuntimeClient();
46+
mockClient = client;
47+
48+
const container = await new GenericContainer("alpine")
49+
.withAutoCleanup(false)
50+
.withWaitStrategy(createNoopWaitStrategy())
51+
.start();
52+
53+
expect(mockGetReaper).not.toHaveBeenCalled();
54+
expect(container.getLabels()[LABEL_TESTCONTAINERS_SESSION_ID]).toBeUndefined();
55+
expect(inspectResult.Config.Labels[LABEL_TESTCONTAINERS_SESSION_ID]).toBeUndefined();
56+
});
57+
});
58+
59+
function createMockContainerRuntimeClient(): {
60+
client: ContainerRuntimeClient;
61+
inspectResult: ContainerInspectInfo;
62+
} {
63+
const inspectResult = {
64+
Config: {
65+
Hostname: "mock-hostname",
66+
Labels: {},
67+
},
68+
HostConfig: {
69+
PortBindings: {},
70+
},
71+
Name: "/mock-container",
72+
NetworkSettings: {
73+
Networks: {},
74+
Ports: {},
75+
},
76+
State: {
77+
FinishedAt: "0001-01-01T00:00:00Z",
78+
Running: true,
79+
StartedAt: new Date().toISOString(),
80+
Status: "running",
81+
},
82+
} as ContainerInspectInfo;
83+
84+
const container = {
85+
id: "mock-container-id",
86+
} as Container;
87+
88+
const client = {
89+
compose: {},
90+
container: {
91+
attach: vi.fn(),
92+
commit: vi.fn(),
93+
connectToNetwork: vi.fn(),
94+
create: vi.fn(async (opts: ContainerCreateOptions) => {
95+
inspectResult.Config.Labels = opts.Labels ?? {};
96+
return container;
97+
}),
98+
dockerode: {} as never,
99+
events: vi.fn(),
100+
exec: vi.fn(),
101+
fetchArchive: vi.fn(),
102+
fetchByLabel: vi.fn(),
103+
getById: vi.fn(),
104+
inspect: vi.fn(async () => inspectResult),
105+
list: vi.fn(),
106+
logs: vi.fn(async () => Readable.from([])),
107+
putArchive: vi.fn(),
108+
remove: vi.fn(),
109+
restart: vi.fn(),
110+
start: vi.fn(),
111+
stop: vi.fn(),
112+
},
113+
image: {
114+
pull: vi.fn(),
115+
},
116+
info: {
117+
containerRuntime: {
118+
host: "localhost",
119+
hostIps: [{ address: "127.0.0.1", family: 4 }],
120+
},
121+
node: {
122+
architecture: "x64",
123+
platform: "linux",
124+
version: process.version,
125+
},
126+
},
127+
network: {
128+
getById: vi.fn(),
129+
},
130+
} as unknown as ContainerRuntimeClient;
131+
132+
return { client, inspectResult };
133+
}
134+
135+
function createNoopWaitStrategy(): WaitStrategy {
136+
return {
137+
waitUntilReady: vi.fn(async () => undefined),
138+
withStartupTimeout: vi.fn().mockReturnThis(),
139+
} as unknown as WaitStrategy;
140+
}

packages/testcontainers/src/generic-container/generic-container.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export class GenericContainer implements TestContainer {
5454
protected environment: Record<string, string> = {};
5555
protected exposedPorts: PortWithOptionalBinding[] = [];
5656
protected reuse = false;
57+
protected autoCleanup = true;
5758
protected autoRemove = true;
5859
protected networkMode?: string;
5960
protected networkAliases: string[] = [];
@@ -112,7 +113,7 @@ export class GenericContainer implements TestContainer {
112113
return this.reuseOrStartContainer(client);
113114
}
114115

115-
if (!this.isReaper()) {
116+
if (!this.isReaper() && this.autoCleanup) {
116117
const reaper = await getReaper(client);
117118
this.createOpts.Labels = { ...this.createOpts.Labels, [LABEL_TESTCONTAINERS_SESSION_ID]: reaper.sessionId };
118119
}
@@ -461,6 +462,11 @@ export class GenericContainer implements TestContainer {
461462
return this;
462463
}
463464

465+
public withAutoCleanup(autoCleanup: boolean): this {
466+
this.autoCleanup = autoCleanup;
467+
return this;
468+
}
469+
464470
public withAutoRemove(autoRemove: boolean): this {
465471
this.autoRemove = autoRemove;
466472
return this;

packages/testcontainers/src/test-container.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export interface TestContainer {
4444
withUser(user: string): this;
4545
withPullPolicy(pullPolicy: ImagePullPolicy): this;
4646
withReuse(): this;
47+
withAutoCleanup(autoCleanup: boolean): this;
4748
withAutoRemove(autoRemove: boolean): this;
4849
withCopyFilesToContainer(filesToCopy: FileToCopy[]): this;
4950
withCopyDirectoriesToContainer(directoriesToCopy: DirectoryToCopy[]): this;

0 commit comments

Comments
 (0)