From bf08e38d184a80126cff7f408d4a997bcae26f3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alpcan=20Ayd=C4=B1n?= Date: Thu, 8 Oct 2026 10:01:33 +0300 Subject: [PATCH 1/2] Start Redpanda only after its config is in place --- .../redpanda/src/redpanda-container.test.ts | 23 +++++++++++++++++++ .../redpanda/src/redpanda-container.ts | 17 +++++++------- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/packages/modules/redpanda/src/redpanda-container.test.ts b/packages/modules/redpanda/src/redpanda-container.test.ts index 824ba8321..93fd14848 100644 --- a/packages/modules/redpanda/src/redpanda-container.test.ts +++ b/packages/modules/redpanda/src/redpanda-container.test.ts @@ -1,9 +1,26 @@ +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); +// Delays every copy into the container, as a busy Docker host does. +class SlowCopyRedpandaContainer extends RedpandaContainer { + protected override async containerStarted( + container: StartedTestContainer, + inspectResult: InspectResult + ): Promise { + const copyContentToContainer = container.copyContentToContainer.bind(container); + container.copyContentToContainer = async (contentsToCopy) => { + await setTimeout(1_000); + return copyContentToContainer(contentsToCopy); + }; + await super.containerStarted(container, inspectResult); + } +} + describe("RedpandaContainer", { timeout: 240_000 }, () => { it("should connect", async () => { // connectToKafka { @@ -13,6 +30,12 @@ describe("RedpandaContainer", { timeout: 240_000 }, () => { // } }); + it("should connect when the config reaches the container late", 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..6ce9e9944 100644 --- a/packages/modules/redpanda/src/redpanda-container.ts +++ b/packages/modules/redpanda/src/redpanda-container.ts @@ -17,8 +17,9 @@ 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_MARKER = "# Injected by testcontainers"; +const WAIT_FOR_CONFIG_MESSAGE = "Waiting for config..."; export class RedpandaContainer extends GenericContainer { private originalWaitStrategy: WaitStrategy | undefined; @@ -42,14 +43,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,12 +58,10 @@ 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 }]); await container.copyContentToContainer([ { content: this.renderRedpandaFile(container.getHost(), container.getMappedPort(REDPANDA_PORT)), - target: "/etc/redpanda/redpanda.yaml", + target: REDPANDA_CONFIG, }, ]); From 7fdc66525c9006ec200282d4e26040edfe1946ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alpcan=20Ayd=C4=B1n?= Date: Thu, 8 Oct 2026 10:24:12 +0300 Subject: [PATCH 2/2] Publish the Redpanda config with a rename --- .../modules/redpanda/src/redpanda-container.test.ts | 12 ++++++++---- packages/modules/redpanda/src/redpanda-container.ts | 10 +++++++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/modules/redpanda/src/redpanda-container.test.ts b/packages/modules/redpanda/src/redpanda-container.test.ts index 93fd14848..59f0111ca 100644 --- a/packages/modules/redpanda/src/redpanda-container.test.ts +++ b/packages/modules/redpanda/src/redpanda-container.test.ts @@ -6,7 +6,7 @@ import { assertMessageProducedAndConsumed } from "./test-helper"; const IMAGE = getImage(__dirname); -// Delays every copy into the container, as a busy Docker host does. +// 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, @@ -14,8 +14,12 @@ class SlowCopyRedpandaContainer extends RedpandaContainer { ): Promise { const copyContentToContainer = container.copyContentToContainer.bind(container); container.copyContentToContainer = async (contentsToCopy) => { - await setTimeout(1_000); - return copyContentToContainer(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); } @@ -30,7 +34,7 @@ describe("RedpandaContainer", { timeout: 240_000 }, () => { // } }); - it("should connect when the config reaches the container late", async () => { + 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); diff --git a/packages/modules/redpanda/src/redpanda-container.ts b/packages/modules/redpanda/src/redpanda-container.ts index 6ce9e9944..60a950498 100644 --- a/packages/modules/redpanda/src/redpanda-container.ts +++ b/packages/modules/redpanda/src/redpanda-container.ts @@ -18,6 +18,7 @@ const REDPANDA_ADMIN_PORT = 9644; const SCHEMA_REGISTRY_PORT = 8081; const REST_PROXY_PORT = 8082; 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..."; @@ -58,12 +59,19 @@ export class RedpandaContainer extends GenericContainer { container: StartedTestContainer, inspectResult: InspectResult ): Promise { + // 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: REDPANDA_CONFIG, + 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());