Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/testcontainers/src/reaper/reaper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,24 @@ describe.sequential("Reaper", { timeout: 120_000 }, () => {
expect(reaper2.containerId).toBe(reaper.containerId);
});

it("should create new reaper container when existing reaper cannot be reached", async () => {
const reaper = await getReaper();
vi.resetModules();
const unreachablePort = await new RandomPortGenerator().generatePort();
const reaperContainerInfo = (await client.container.list()).filter((c) => c.Id === reaper.containerId)[0];
reaperContainerInfo.Labels["TESTCONTAINERS_RYUK_TEST_LABEL"] = "false";
const reaperPort = reaperContainerInfo.Ports.find((port) => port.PrivatePort == 8080);
if (!reaperPort) {
throw new Error("Expected Reaper to map exposed port 8080");
}
reaperPort.PublicPort = unreachablePort;
vi.spyOn(client.container, "list").mockResolvedValue([reaperContainerInfo]);

const reaper2 = await getReaper();

expect(reaper2.containerId).not.toBe(reaper.containerId);
});

it("should use custom port when TESTCONTAINERS_RYUK_PORT is set", async () => {
const customPort = (await new RandomPortGenerator().generatePort()).toString();
vi.stubEnv("TESTCONTAINERS_RYUK_PORT", customPort);
Expand Down
40 changes: 27 additions & 13 deletions packages/testcontainers/src/reaper/reaper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,30 +29,44 @@ export async function getReaper(client: ContainerRuntimeClient): Promise<Reaper>
}

reaper = await withFileLock("testcontainers-node.lock", async () => {
const reaperContainer = await findReaperContainer(client);
sessionId = reaperContainer?.Labels[LABEL_TESTCONTAINERS_SESSION_ID] ?? new RandomUuid().nextUuid();
const reaperContainers = await findReaperContainers(client);

if (process.env.TESTCONTAINERS_RYUK_DISABLED === "true") {
sessionId = new RandomUuid().nextUuid();
return new DisabledReaper(sessionId, "");
} else if (reaperContainer) {
return await useExistingReaper(reaperContainer, sessionId, client.info.containerRuntime.host);
} else {
return await createNewReaper(sessionId, client.info.containerRuntime.remoteSocketPath);
}

for (const reaperContainer of reaperContainers) {
const existingSessionId = reaperContainer.Labels[LABEL_TESTCONTAINERS_SESSION_ID] ?? new RandomUuid().nextUuid();
try {
sessionId = existingSessionId;
return await useExistingReaper(reaperContainer, sessionId, client.info.containerRuntime.host);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.warn(`Failed to reuse existing Reaper: ${message}. Trying another Reaper...`, {
containerId: reaperContainer.Id,
});
}
}

sessionId = new RandomUuid().nextUuid();
return await createNewReaper(sessionId, client.info.containerRuntime.remoteSocketPath);
});

reaper.addSession(sessionId);
return reaper;
}

async function findReaperContainer(client: ContainerRuntimeClient): Promise<ContainerInfo | undefined> {
async function findReaperContainers(client: ContainerRuntimeClient): Promise<ContainerInfo[]> {
const containers = await client.container.list();
return containers.find(
(container) =>
container.State === "running" &&
container.Labels[LABEL_TESTCONTAINERS_RYUK] === "true" &&
container.Labels["TESTCONTAINERS_RYUK_TEST_LABEL"] !== "true"
);
return containers
.filter(
(container) =>
container.State === "running" &&
container.Labels[LABEL_TESTCONTAINERS_RYUK] === "true" &&
container.Labels["TESTCONTAINERS_RYUK_TEST_LABEL"] !== "true"
)
.sort((a, b) => b.Created - a.Created);
}

async function useExistingReaper(reaperContainer: ContainerInfo, sessionId: string, host: string): Promise<Reaper> {
Expand Down
Loading