Skip to content

Commit 0bc8c42

Browse files
authored
Add OneShotWaitStrategy (#730)
1 parent 9ffe363 commit 0bc8c42

File tree

6 files changed

+93
-34
lines changed

6 files changed

+93
-34
lines changed

docs/features/wait-strategies.md

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,7 @@ The default wait strategy used by Testcontainers. It will wait up to 60 seconds
1717
```javascript
1818
const { GenericContainer } = require("testcontainers");
1919

20-
const container = await new GenericContainer("alpine")
21-
.withExposedPorts(6379)
22-
.start();
20+
const container = await new GenericContainer("alpine").withExposedPorts(6379).start();
2321
```
2422

2523
It can be set explicitly but is not required:
@@ -72,9 +70,7 @@ Wait until the container's health check is successful:
7270
```javascript
7371
const { GenericContainer, Wait } = require("testcontainers");
7472

75-
const container = await new GenericContainer("alpine")
76-
.withWaitStrategy(Wait.forHealthCheck())
77-
.start();
73+
const container = await new GenericContainer("alpine").withWaitStrategy(Wait.forHealthCheck()).start();
7874
```
7975

8076
Define your own health check:
@@ -88,7 +84,7 @@ const container = await new GenericContainer("alpine")
8884
interval: 1000,
8985
timeout: 3000,
9086
retries: 5,
91-
startPeriod: 1000
87+
startPeriod: 1000,
9288
})
9389
.withWaitStrategy(Wait.forHealthCheck())
9490
.start();
@@ -99,13 +95,13 @@ Note that `interval`, `timeout`, `retries` and `startPeriod` are optional as the
9995
To execute the test with a shell use the form `["CMD-SHELL", "command"]`:
10096

10197
```javascript
102-
["CMD-SHELL", "curl -f http://localhost:8000 || exit 1"]
98+
["CMD-SHELL", "curl -f http://localhost:8000 || exit 1"];
10399
```
104100

105101
To execute the test without a shell, use the form: `["CMD", "command", "arg1", "arg2"]`. This may be needed when working with distroless images:
106102

107103
```javascript
108-
["CMD", "/usr/bin/wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/hello-world"]
104+
["CMD", "/usr/bin/wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/hello-world"];
109105
```
110106

111107
## HTTP
@@ -115,9 +111,7 @@ Wait for an HTTP request to satisfy a condition. By default, it will wait for a
115111
```javascript
116112
const { GenericContainer, Wait } = require("testcontainers");
117113

118-
const container = await new GenericContainer("redis")
119-
.withWaitStrategy(Wait.forHttp("/health", 8080))
120-
.start();
114+
const container = await new GenericContainer("redis").withWaitStrategy(Wait.forHttp("/health", 8080)).start();
121115
```
122116

123117
Stop waiting after container exited if waiting for container restart not needed.
@@ -184,6 +178,18 @@ const container = await new GenericContainer("alpine")
184178
.start();
185179
```
186180

181+
## One shot
182+
183+
This strategy is intended for use with containers that only run briefly and exit of their own accord. As such, success is deemed to be when the container has stopped with exit code 0.
184+
185+
```javascript
186+
const { GenericContainer, Wait } = require("testcontainers");
187+
188+
const container = await new GenericContainer("alpine")
189+
.withWaitStrategy(Wait.forOneShotStartup()))
190+
.start();
191+
```
192+
187193
## Composite
188194

189195
Multiple wait strategies can be chained together:
@@ -192,10 +198,7 @@ Multiple wait strategies can be chained together:
192198
const { GenericContainer, Wait } = require("testcontainers");
193199

194200
const container = await new GenericContainer("alpine")
195-
.withWaitStrategy(Wait.forAll([
196-
Wait.forListeningPorts(),
197-
Wait.forLogMessage("Ready to accept connections")
198-
]))
201+
.withWaitStrategy(Wait.forAll([Wait.forListeningPorts(), Wait.forLogMessage("Ready to accept connections")]))
199202
.start();
200203
```
201204

@@ -205,7 +208,7 @@ The composite wait strategy by default will respect each individual wait strateg
205208
const w1 = Wait.forListeningPorts().withStartupTimeout(1000);
206209
const w2 = Wait.forLogMessage("READY").withStartupTimeout(2000);
207210

208-
const composite = Wait.forAll([w1, w2])
211+
const composite = Wait.forAll([w1, w2]);
209212

210213
expect(w1.getStartupTimeout()).toBe(1000);
211214
expect(w2.getStartupTimeout()).toBe(2000);
@@ -217,7 +220,7 @@ The startup timeout of inner wait strategies that have not defined their own sta
217220
const w1 = Wait.forListeningPorts().withStartupTimeout(1000);
218221
const w2 = Wait.forLogMessage("READY");
219222

220-
const composite = Wait.forAll([w1, w2]).withStartupTimeout(2000)
223+
const composite = Wait.forAll([w1, w2]).withStartupTimeout(2000);
221224

222225
expect(w1.getStartupTimeout()).toBe(1000);
223226
expect(w2.getStartupTimeout()).toBe(2000);
@@ -228,7 +231,7 @@ The startup timeout of all wait strategies can be controlled by setting a deadli
228231
```javascript
229232
const w1 = Wait.forListeningPorts();
230233
const w2 = Wait.forLogMessage("READY");
231-
const composite = Wait.forAll([w1, w2]).withDeadline(2000)
234+
const composite = Wait.forAll([w1, w2]).withDeadline(2000);
232235
```
233236

234237
## Other startup strategies
@@ -237,8 +240,8 @@ If these options do not meet your requirements, you can subclass `StartupCheckSt
237240

238241
```javascript
239242
const Dockerode = require("dockerode");
240-
const {
241-
GenericContainer,
243+
const {
244+
GenericContainer,
242245
StartupCheckStrategy,
243246
StartupStatus
244247
} = require("testcontainers");
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { GenericContainer } from "../generic-container/generic-container";
2+
import { Wait } from "./wait";
3+
4+
jest.setTimeout(180_000);
5+
6+
describe("OneShotStartupCheckStrategy", () => {
7+
it("should wait for container to finish", async () => {
8+
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
9+
.withCommand(["/bin/sh", "-c", 'sleep 2; echo "Ready"'])
10+
.withWaitStrategy(Wait.forOneShotStartup())
11+
.start();
12+
13+
await container.stop();
14+
});
15+
16+
it("should fail if container did not finish succesfully", async () => {
17+
await expect(() =>
18+
new GenericContainer("cristianrgreco/testcontainer:1.1.14")
19+
.withCommand(["/bin/sh", "-c", "not-existing"])
20+
.withWaitStrategy(Wait.forOneShotStartup())
21+
.start()
22+
).rejects.toThrow("Container failed to start for");
23+
});
24+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import Dockerode, { ContainerInspectInfo } from "dockerode";
2+
import { StartupCheckStrategy, StartupStatus } from "./startup-check-strategy";
3+
4+
export class OneShotStartupCheckStrategy extends StartupCheckStrategy {
5+
DOCKER_TIMESTAMP_ZERO = "0001-01-01T00:00:00Z";
6+
7+
private isDockerTimestampNonEmpty(dockerTimestamp: string) {
8+
return dockerTimestamp !== "" && dockerTimestamp !== this.DOCKER_TIMESTAMP_ZERO && Date.parse(dockerTimestamp) > 0;
9+
}
10+
11+
private isContainerStopped({ State: state }: ContainerInspectInfo): boolean {
12+
if (state.Running || state.Paused) {
13+
return false;
14+
}
15+
16+
return this.isDockerTimestampNonEmpty(state.StartedAt) && this.isDockerTimestampNonEmpty(state.FinishedAt);
17+
}
18+
19+
public async checkStartupState(dockerClient: Dockerode, containerId: string): Promise<StartupStatus> {
20+
const info = await dockerClient.getContainer(containerId).inspect();
21+
22+
if (!this.isContainerStopped(info)) {
23+
return "PENDING";
24+
}
25+
26+
if (info.State.ExitCode === 0) {
27+
return "SUCCESS";
28+
}
29+
30+
return "FAIL";
31+
}
32+
}

packages/testcontainers/src/wait-strategies/startup-check-strategy.test.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,9 @@
11
import { GenericContainer } from "../generic-container/generic-container";
22
import { StartupCheckStrategy, StartupStatus } from "./startup-check-strategy";
3-
import { ContainerRuntimeClient, getContainerRuntimeClient } from "../container-runtime";
43

54
jest.setTimeout(180_000);
65

76
describe("StartupCheckStrategy", () => {
8-
let client: ContainerRuntimeClient;
9-
10-
beforeAll(async () => {
11-
client = await getContainerRuntimeClient();
12-
});
13-
147
it("should wait until ready", async () => {
158
const waitStrategy = new (class extends StartupCheckStrategy {
169
private count = 0;
@@ -23,7 +16,7 @@ describe("StartupCheckStrategy", () => {
2316
return "SUCCESS";
2417
}
2518
}
26-
})(client);
19+
})();
2720

2821
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
2922
.withWaitStrategy(waitStrategy)
@@ -37,7 +30,7 @@ describe("StartupCheckStrategy", () => {
3730
public override async checkStartupState(): Promise<StartupStatus> {
3831
return "PENDING";
3932
}
40-
})(client);
33+
})();
4134

4235
await expect(() =>
4336
new GenericContainer("cristianrgreco/testcontainer:1.1.14")
@@ -55,7 +48,7 @@ describe("StartupCheckStrategy", () => {
5548
this.count++;
5649
return "FAIL";
5750
}
58-
})(client);
51+
})();
5952

6053
await expect(() =>
6154
new GenericContainer("cristianrgreco/testcontainer:1.1.14")

packages/testcontainers/src/wait-strategies/startup-check-strategy.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
import { AbstractWaitStrategy } from "./wait-strategy";
22
import Dockerode from "dockerode";
3-
import { ContainerRuntimeClient } from "../container-runtime";
3+
import { getContainerRuntimeClient } from "../container-runtime";
44
import { IntervalRetry, log } from "../common";
55

66
export type StartupStatus = "PENDING" | "SUCCESS" | "FAIL";
77

88
export abstract class StartupCheckStrategy extends AbstractWaitStrategy {
9-
constructor(private readonly client: ContainerRuntimeClient) {
9+
constructor() {
1010
super();
1111
}
1212

1313
public abstract checkStartupState(dockerClient: Dockerode, containerId: string): Promise<StartupStatus>;
1414

1515
public override async waitUntilReady(container: Dockerode.Container): Promise<void> {
16+
const client = await getContainerRuntimeClient();
17+
1618
const startupStatus = await new IntervalRetry<StartupStatus, Error>(1000).retryUntil(
17-
async () => await this.checkStartupState(this.client.container.dockerode, container.id),
19+
async () => await this.checkStartupState(client.container.dockerode, container.id),
1820
(startupStatus) => startupStatus === "SUCCESS" || startupStatus === "FAIL",
1921
() => {
2022
const message = `Container not accessible after ${this.startupTimeout}ms`;

packages/testcontainers/src/wait-strategies/wait.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Log, LogWaitStrategy } from "./log-wait-strategy";
55
import { ShellWaitStrategy } from "./shell-wait-strategy";
66
import { HostPortWaitStrategy } from "./host-port-wait-strategy";
77
import { CompositeWaitStrategy } from "./composite-wait-strategy";
8+
import { OneShotStartupCheckStrategy } from "./one-shot-startup-startegy";
89

910
export class Wait {
1011
public static forAll(waitStrategies: WaitStrategy[]): CompositeWaitStrategy {
@@ -23,6 +24,10 @@ export class Wait {
2324
return new HealthCheckWaitStrategy();
2425
}
2526

27+
public static forOneShotStartup(): WaitStrategy {
28+
return new OneShotStartupCheckStrategy();
29+
}
30+
2631
public static forHttp(
2732
path: string,
2833
port: number,

0 commit comments

Comments
 (0)