diff --git a/packages/modules/redpanda/src/redpanda-container.test.ts b/packages/modules/redpanda/src/redpanda-container.test.ts index 824ba8321..59f0111ca 100644 --- a/packages/modules/redpanda/src/redpanda-container.test.ts +++ b/packages/modules/redpanda/src/redpanda-container.test.ts @@ -1,9 +1,30 @@ +import { setTimeout } from "node:timers/promises"; +import type { InspectResult, StartedTestContainer } from "testcontainers"; import { getImage } from "../../../testcontainers/src/utils/test-helper"; import { RedpandaContainer } from "./redpanda-container"; import { assertMessageProducedAndConsumed } from "./test-helper"; const IMAGE = getImage(__dirname); +// Writes each copied file in two parts with a pause between them, as a busy Docker host can. +class SlowCopyRedpandaContainer extends RedpandaContainer { + protected override async containerStarted( + container: StartedTestContainer, + inspectResult: InspectResult + ): Promise { + const copyContentToContainer = container.copyContentToContainer.bind(container); + container.copyContentToContainer = async (contentsToCopy) => { + for (const { content, target, mode } of contentsToCopy) { + const [firstLine] = String(content).split("\n", 1); + await copyContentToContainer([{ content: `${firstLine}\n`, target, mode }]); + await setTimeout(1_000); + await copyContentToContainer([{ content, target, mode }]); + } + }; + await super.containerStarted(container, inspectResult); + } +} + describe("RedpandaContainer", { timeout: 240_000 }, () => { it("should connect", async () => { // connectToKafka { @@ -13,6 +34,12 @@ describe("RedpandaContainer", { timeout: 240_000 }, () => { // } }); + it("should connect when the config reaches the container late and in parts", async () => { + await using container = await new SlowCopyRedpandaContainer(IMAGE).start(); + + await assertMessageProducedAndConsumed(container); + }); + it("should connect to schema registry", async () => { // connectToSchemaRegistry { await using container = await new RedpandaContainer(IMAGE).start(); diff --git a/packages/modules/redpanda/src/redpanda-container.ts b/packages/modules/redpanda/src/redpanda-container.ts index 9f9325857..60a950498 100644 --- a/packages/modules/redpanda/src/redpanda-container.ts +++ b/packages/modules/redpanda/src/redpanda-container.ts @@ -17,8 +17,10 @@ const REDPANDA_PORT = 9092; const REDPANDA_ADMIN_PORT = 9644; const SCHEMA_REGISTRY_PORT = 8081; const REST_PROXY_PORT = 8082; -const STARTER_SCRIPT = "/testcontainers_start.sh"; -const WAIT_FOR_SCRIPT_MESSAGE = "Waiting for script..."; +const REDPANDA_CONFIG = "/etc/redpanda/redpanda.yaml"; +const REDPANDA_CONFIG_STAGING = `${REDPANDA_CONFIG}.testcontainers`; +const REDPANDA_CONFIG_MARKER = "# Injected by testcontainers"; +const WAIT_FOR_CONFIG_MESSAGE = "Waiting for config..."; export class RedpandaContainer extends GenericContainer { private originalWaitStrategy: WaitStrategy | undefined; @@ -42,14 +44,14 @@ export class RedpandaContainer extends GenericContainer { } protected override async beforeContainerCreated(): Promise { - // Change the wait strategy to wait for a log message from a fake starter script - // so that we can put a real starter script in place at that moment + // Change the wait strategy to wait for a log message from a fake starter, + // which starts Redpanda only once the config with the mapped Kafka port is in place this.originalWaitStrategy = this.waitStrategy; - this.waitStrategy = Wait.forLogMessage(WAIT_FOR_SCRIPT_MESSAGE); + this.waitStrategy = Wait.forLogMessage(WAIT_FOR_CONFIG_MESSAGE); this.withEntrypoint(["sh"]); this.withCommand([ "-c", - `echo '${WAIT_FOR_SCRIPT_MESSAGE}'; while [ ! -f ${STARTER_SCRIPT} ]; do sleep 0.1; done; ${STARTER_SCRIPT}`, + `echo '${WAIT_FOR_CONFIG_MESSAGE}'; until grep -q '${REDPANDA_CONFIG_MARKER}' ${REDPANDA_CONFIG}; do sleep 0.1; done; exec rpk redpanda start --mode dev-container --smp=1 --memory=1G`, ]); } @@ -57,14 +59,19 @@ export class RedpandaContainer extends GenericContainer { container: StartedTestContainer, inspectResult: InspectResult ): Promise { - const command = "#!/bin/bash\nrpk redpanda start --mode dev-container --smp=1 --memory=1G"; - await container.copyContentToContainer([{ content: command, target: STARTER_SCRIPT, mode: 0o777 }]); + // Docker writes a copied file in place, so the starter could read the marker before the rest. + // A rename on the same filesystem publishes the whole file at once. await container.copyContentToContainer([ { content: this.renderRedpandaFile(container.getHost(), container.getMappedPort(REDPANDA_PORT)), - target: "/etc/redpanda/redpanda.yaml", + target: REDPANDA_CONFIG_STAGING, }, ]); + const { exitCode, output } = await container.exec(["mv", REDPANDA_CONFIG_STAGING, REDPANDA_CONFIG]); + + if (exitCode !== 0) { + throw new Error(`Redpanda container configuration failed with exit code ${exitCode}: ${output}`); + } const client = await getContainerRuntimeClient(); const dockerContainer = client.container.getById(container.getId());