diff --git a/package-lock.json b/package-lock.json index f56d132a1..be9b58df1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7893,9 +7893,10 @@ } }, "node_modules/@types/dockerode": { - "version": "3.3.42", - "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.42.tgz", - "integrity": "sha512-U1jqHMShibMEWHdxYhj3rCMNCiLx5f35i4e3CEUuW+JSSszc/tVqc6WCAPdhwBymG5R/vgbcceagK0St7Cq6Eg==", + "version": "3.3.43", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.43.tgz", + "integrity": "sha512-YCi0aKKpKeC9dhKTbuglvsWDnAyuIITd6CCJSTKiAdbDzPH4RWu0P9IK2XkJHdyplH6mzYtDYO+gB06JlzcPxg==", + "license": "MIT", "dependencies": { "@types/docker-modem": "*", "@types/node": "*", @@ -22897,7 +22898,7 @@ "license": "MIT", "dependencies": { "@balena/dockerignore": "^1.0.2", - "@types/dockerode": "^3.3.42", + "@types/dockerode": "^3.3.43", "archiver": "^7.0.1", "async-lock": "^1.4.1", "byline": "^5.0.0", diff --git a/packages/modules/kafka/src/kafka-container.ts b/packages/modules/kafka/src/kafka-container.ts index 0cbfa98b8..36d9bec31 100644 --- a/packages/modules/kafka/src/kafka-container.ts +++ b/packages/modules/kafka/src/kafka-container.ts @@ -61,7 +61,7 @@ export class KafkaContainer extends GenericContainer { private zooKeeperHost?: string; private zooKeeperPort?: number; private saslSslConfig?: SaslSslListenerOptions; - private originalWaitinStrategy: WaitStrategy; + private originalWaitinStrategy: WaitStrategy | undefined; constructor(image: string) { super(image); @@ -193,7 +193,12 @@ export class KafkaContainer extends GenericContainer { const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult).filter( this.exposedPorts ); - await waitForContainer(client, dockerContainer, this.originalWaitinStrategy, boundPorts); + await waitForContainer( + client, + dockerContainer, + this.originalWaitinStrategy ?? Wait.forListeningPorts(), + boundPorts + ); if (this.saslSslConfig && this.mode !== KafkaMode.KRAFT) { await this.createUser(container, this.saslSslConfig.sasl); diff --git a/packages/modules/redpanda/src/redpanda-container.ts b/packages/modules/redpanda/src/redpanda-container.ts index ee2025213..f104b76e8 100644 --- a/packages/modules/redpanda/src/redpanda-container.ts +++ b/packages/modules/redpanda/src/redpanda-container.ts @@ -21,7 +21,7 @@ const STARTER_SCRIPT = "/testcontainers_start.sh"; const WAIT_FOR_SCRIPT_MESSAGE = "Waiting for script..."; export class RedpandaContainer extends GenericContainer { - private originalWaitinStrategy: WaitStrategy; + private originalWaitinStrategy: WaitStrategy | undefined; constructor(image: string) { super(image); @@ -71,7 +71,12 @@ export class RedpandaContainer extends GenericContainer { const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult).filter( this.exposedPorts ); - await waitForContainer(client, dockerContainer, this.originalWaitinStrategy, boundPorts); + await waitForContainer( + client, + dockerContainer, + this.originalWaitinStrategy ?? Wait.forListeningPorts(), + boundPorts + ); } private renderRedpandaFile(host: string, port: number): string { diff --git a/packages/testcontainers/package.json b/packages/testcontainers/package.json index d661e1ec3..e41f5d735 100644 --- a/packages/testcontainers/package.json +++ b/packages/testcontainers/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@balena/dockerignore": "^1.0.2", - "@types/dockerode": "^3.3.42", + "@types/dockerode": "^3.3.43", "archiver": "^7.0.1", "async-lock": "^1.4.1", "byline": "^5.0.0", diff --git a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts index 9a077642b..5bb3abc42 100644 --- a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts +++ b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts @@ -10,6 +10,8 @@ import { getVolumeNames, waitForDockerEvent, } from "../utils/test-helper"; +import { HealthCheckWaitStrategy } from "../wait-strategies/health-check-wait-strategy"; +import { HostPortWaitStrategy } from "../wait-strategies/host-port-wait-strategy"; import { Wait } from "../wait-strategies/wait"; import { DockerComposeEnvironment } from "./docker-compose-environment"; @@ -97,6 +99,31 @@ describe("DockerComposeEnvironment", { timeout: 180_000 }, () => { await checkEnvironmentContainerIsHealthy(startedEnvironment, await composeContainerName("container")); }); + if (!process.env.CI_PODMAN) { + it("should use wait strategy Wait.forHealthCheck() if healthcheck is defined in service", async () => { + await using startedEnvironment = await new DockerComposeEnvironment( + fixtures, + "docker-compose-with-healthcheck.yml" + ).up(); + + await checkEnvironmentContainerIsHealthy(startedEnvironment, await composeContainerName("container")); + + const waitStrategy = startedEnvironment.getContainer("container-1")["getWaitStrategy"](); + expect(waitStrategy).toBeInstanceOf(HealthCheckWaitStrategy); + }); + it("should use wait strategy Wait.forListeningPorts() if healthcheck is NOT defined in service", async () => { + await using startedEnvironment = await new DockerComposeEnvironment( + fixtures, + "docker-compose-with-name.yml" + ).up(); + + await checkEnvironmentContainerIsHealthy(startedEnvironment, "custom_container_name"); + + const waitStrategy = startedEnvironment.getContainer("custom_container_name")["getWaitStrategy"](); + expect(waitStrategy).toBeInstanceOf(HostPortWaitStrategy); + }); + } + it("should support log message wait strategy", async () => { await using startedEnvironment = await new DockerComposeEnvironment(fixtures, "docker-compose.yml") .withWaitStrategy(await composeContainerName("container"), Wait.forLogMessage("Listening on port 8080")) diff --git a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts index 40273b48b..210dc9431 100644 --- a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts +++ b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts @@ -1,4 +1,4 @@ -import { ContainerInfo } from "dockerode"; +import { ContainerInfo, ContainerInspectInfo } from "dockerode"; import { containerLog, log, RandomUuid, Uuid } from "../common"; import { ComposeOptions, getContainerRuntimeClient, parseComposeContainerName } from "../container-runtime"; import { StartedGenericContainer } from "../generic-container/started-generic-container"; @@ -23,7 +23,7 @@ export class DockerComposeEnvironment { private profiles: string[] = []; private environment: Environment = {}; private pullPolicy: ImagePullPolicy = PullPolicy.defaultPolicy(); - private defaultWaitStrategy: WaitStrategy = Wait.forListeningPorts(); + private defaultWaitStrategy: WaitStrategy | undefined; private waitStrategy: { [containerName: string]: WaitStrategy } = {}; private startupTimeoutMs?: number; private clientOptions: Partial = {}; @@ -159,9 +159,7 @@ export class DockerComposeEnvironment { const inspectResult = await client.container.inspect(container); const mappedInspectResult = mapInspectResult(inspectResult); const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult); - const waitStrategy = this.waitStrategy[containerName] - ? this.waitStrategy[containerName] - : this.defaultWaitStrategy; + const waitStrategy = this.selectWaitStrategy(containerName, inspectResult); if (this.startupTimeoutMs !== undefined) { waitStrategy.withStartupTimeout(this.startupTimeoutMs); } @@ -207,4 +205,16 @@ export class DockerComposeEnvironment { environment: this.environment, }); } + + private selectWaitStrategy(containerName: string, inspectResult: ContainerInspectInfo): WaitStrategy { + const containerWaitStrategy = this.waitStrategy[containerName] + ? this.waitStrategy[containerName] + : this.defaultWaitStrategy; + if (containerWaitStrategy) return containerWaitStrategy; + const healthcheck = inspectResult.Config.Healthcheck; + if (healthcheck?.Test) { + return Wait.forHealthCheck(); + } + return Wait.forListeningPorts(); + } } diff --git a/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts b/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts new file mode 100644 index 000000000..6f1e12be8 --- /dev/null +++ b/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts @@ -0,0 +1,51 @@ +import path from "path"; +import { HealthCheckWaitStrategy } from "../wait-strategies/health-check-wait-strategy"; +import { HostPortWaitStrategy } from "../wait-strategies/host-port-wait-strategy"; +import { Wait } from "../wait-strategies/wait"; +import { GenericContainer } from "./generic-container"; +import { StartedGenericContainer } from "./started-generic-container"; + +const fixtures = path.resolve(__dirname, "..", "..", "fixtures", "docker"); + +describe("GenericContainer wait strategy", { timeout: 180_000 }, () => { + it("should use Wait.forListeningPorts if healthcheck is not defined in DOCKERFILE", async () => { + await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withExposedPorts(8080) + .start(); + expect((container as StartedGenericContainer)["getWaitStrategy"]()).toBeInstanceOf(HostPortWaitStrategy); + }); + it("should use Wait.forHealthCheck if withHealthCheck() explicitly called", async () => { + await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withExposedPorts(8080) + .withHealthCheck({ + test: ["CMD-SHELL", "echo 'started' && exit 0"], + }) + .start(); + expect((container as StartedGenericContainer)["getWaitStrategy"]()).toBeInstanceOf(HealthCheckWaitStrategy); + }); + it("should use Wait.forHealthCheck if healthcheck is defined in DOCKERFILE", async () => { + const context = path.resolve(fixtures, "docker-with-health-check"); + const genericContainer = await GenericContainer.fromDockerfile(context).build(); + await using startedContainer = await genericContainer.start(); + expect((startedContainer as StartedGenericContainer)["getWaitStrategy"]()).toBeInstanceOf(HealthCheckWaitStrategy); + }); + it("should use same WaitStrategy if it's explicitly defined in withWaitStrategy() even if image defines healthcheck", async () => { + const context = path.resolve(fixtures, "docker-with-health-check"); + const genericContainer = await GenericContainer.fromDockerfile(context).build(); + await using container = await genericContainer + .withExposedPorts(8080) + .withWaitStrategy(Wait.forListeningPorts()) + .start(); + expect((container as StartedGenericContainer)["getWaitStrategy"]()).toBeInstanceOf(HostPortWaitStrategy); + }); + it("should use same WaitStrategy if it's explicitly defined in withWaitStrategy() even if withHealthCheck() is called", async () => { + await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withExposedPorts(8080) + .withHealthCheck({ + test: ["CMD-SHELL", "echo 'started' && exit 0"], + }) + .withWaitStrategy(Wait.forListeningPorts()) + .start(); + expect((container as StartedGenericContainer)["getWaitStrategy"]()).toBeInstanceOf(HostPortWaitStrategy); + }); +}); diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index bada4e422..6a459c397 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -48,7 +48,7 @@ export class GenericContainer implements TestContainer { protected imageName: ImageName; protected startupTimeoutMs?: number; - protected waitStrategy: WaitStrategy = Wait.forListeningPorts(); + protected waitStrategy: WaitStrategy | undefined; protected environment: Record = {}; protected exposedPorts: PortWithOptionalBinding[] = []; protected reuse = false; @@ -117,6 +117,18 @@ export class GenericContainer implements TestContainer { return this.startContainer(client); } + private async selectWaitStrategy(client: ContainerRuntimeClient, container: Container): Promise { + if (this.waitStrategy) return this.waitStrategy; + if (this.healthCheck) { + return Wait.forHealthCheck(); + } + const containerInfo = await client.container.inspect(container); + if (containerInfo.Config.Healthcheck?.Test) { + return Wait.forHealthCheck(); + } + return Wait.forListeningPorts(); + } + private async reuseOrStartContainer(client: ContainerRuntimeClient) { const containerHash = hash(JSON.stringify(this.createOpts)); this.createOpts.Labels = { ...this.createOpts.Labels, [LABEL_TESTCONTAINERS_CONTAINER_HASH]: containerHash }; @@ -150,10 +162,12 @@ export class GenericContainer implements TestContainer { this.exposedPorts ); if (this.startupTimeoutMs !== undefined) { - this.waitStrategy.withStartupTimeout(this.startupTimeoutMs); + this.waitStrategy?.withStartupTimeout(this.startupTimeoutMs); } - await waitForContainer(client, container, this.waitStrategy, boundPorts); + const waitStrategy = this.waitStrategy ?? Wait.forListeningPorts(); + + await waitForContainer(client, container, waitStrategy, boundPorts); return new StartedGenericContainer( container, @@ -161,7 +175,7 @@ export class GenericContainer implements TestContainer { inspectResult, boundPorts, inspectResult.Name, - this.waitStrategy, + waitStrategy, this.autoRemove ); } @@ -169,6 +183,8 @@ export class GenericContainer implements TestContainer { private async startContainer(client: ContainerRuntimeClient): Promise { const container = await client.container.create({ ...this.createOpts, HostConfig: this.hostConfig }); + this.waitStrategy = await this.selectWaitStrategy(client, container); + if (!this.isHelperContainer() && PortForwarderInstance.isRunning()) { await this.connectContainerToPortForwarder(client, container); } @@ -206,7 +222,7 @@ export class GenericContainer implements TestContainer { ); if (this.startupTimeoutMs !== undefined) { - this.waitStrategy.withStartupTimeout(this.startupTimeoutMs); + this.waitStrategy?.withStartupTimeout(this.startupTimeoutMs); } if (containerLog.enabled() || this.logConsumer !== undefined) { @@ -225,7 +241,9 @@ export class GenericContainer implements TestContainer { await this.containerStarting(mappedInspectResult, false); } - await waitForContainer(client, container, this.waitStrategy, boundPorts); + const waitStrategy = this.waitStrategy ?? Wait.forListeningPorts(); + + await waitForContainer(client, container, waitStrategy, boundPorts); const startedContainer = new StartedGenericContainer( container, @@ -233,7 +251,7 @@ export class GenericContainer implements TestContainer { inspectResult, boundPorts, inspectResult.Name, - this.waitStrategy, + waitStrategy, this.autoRemove ); diff --git a/packages/testcontainers/src/generic-container/started-generic-container.ts b/packages/testcontainers/src/generic-container/started-generic-container.ts index 32f7d3925..5943fed4e 100644 --- a/packages/testcontainers/src/generic-container/started-generic-container.ts +++ b/packages/testcontainers/src/generic-container/started-generic-container.ts @@ -244,4 +244,8 @@ export class StartedGenericContainer implements StartedTestContainer { async [Symbol.asyncDispose]() { await this.stop(); } + + private getWaitStrategy() { + return this.waitStrategy; + } }