Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
11 changes: 11 additions & 0 deletions packages/testcontainers/fixtures/docker/docker-no-shell/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
FROM node AS build
WORKDIR /app
RUN npm init -y \
&& npm install [email protected]
COPY index.js .

FROM gcr.io/distroless/nodejs20-debian12 AS app
EXPOSE 8080
COPY --from=build /app /app
WORKDIR /app
CMD ["index.js"]
14 changes: 14 additions & 0 deletions packages/testcontainers/fixtures/docker/docker-no-shell/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const http = require("http");
const express = require("express");

const app = express();

app.get("/hello-world", (req, res) => {
res.status(200).send("hello-world");
});

const PORT = 8080;

http
.createServer(app)
.listen(PORT, () => console.log(`Listening on port ${PORT}`));
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import path from "path";
import { getContainerRuntimeClient } from "../../container-runtime";
import { GenericContainer } from "../../generic-container/generic-container";
import { checkContainerIsHealthy } from "../../utils/test-helper";
import { Wait } from "../wait";
import { InternalPortCheck } from "./port-check";

describe("PortCheck", { timeout: 120_000 }, () => {
describe("InternalPortCheck", () => {
it("should work on container with shell", async () => {
const client = await getContainerRuntimeClient();
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
.withExposedPorts(8080)
.withWaitStrategy(Wait.forLogMessage(/Listening on port 8080/))
.start();

await checkContainerIsHealthy(container);

const lowerLevelContainer = client.container.dockerode.getContainer(container.getId());
const portCheck = new InternalPortCheck(client, lowerLevelContainer);

const isBound = await portCheck.isBound(8080);
expect(isBound).toBeTruthy();

await container.stop();
});

const fixtures = path.resolve(__dirname, "..", "..", "..", "fixtures", "docker");
it("should fail on container without shell (distroless)", async () => {
const context = path.resolve(fixtures, "docker-no-shell");
const container = await GenericContainer.fromDockerfile(context).build();
const startedContainer = await container
.withExposedPorts(8080)
.withWaitStrategy(Wait.forLogMessage(/Listening on port 8080/))
.start();

await checkContainerIsHealthy(startedContainer);

const client = await getContainerRuntimeClient();
const lowerLevelContainer = client.container.dockerode.getContainer(startedContainer.getId());
const portCheck = new InternalPortCheck(client, lowerLevelContainer);

const isBound = await portCheck.isBound(8080);
expect(isBound).toBeFalsy();

await startedContainer.stop();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -140,5 +140,43 @@ describe("PortCheck", () => {
],
]);
});

it("should error log the distroless image once, regardless of logs enabled or not", async () => {
// Make sure logging is disabled explicitly here
mockLogger.enabled.mockImplementation(() => false);

mockContainerExec
.mockReturnValueOnce(Promise.resolve({ output: "ERROR 1", exitCode: 126 }))
.mockReturnValueOnce(Promise.resolve({ output: "ERROR 2", exitCode: 126 }))
.mockReturnValueOnce(Promise.resolve({ output: "ERROR 2", exitCode: 126 }))
.mockReturnValueOnce(Promise.resolve({ output: "ERROR 1", exitCode: 126 }))
.mockReturnValueOnce(Promise.resolve({ output: "ERROR 2", exitCode: 126 }))
.mockReturnValueOnce(Promise.resolve({ output: "ERROR 2", exitCode: 126 }));

await portCheck.isBound(8080);
await portCheck.isBound(8080);

expect(mockLogger.error.mock.calls).toEqual([
[
"The HostPortWaitStrategy will not work on a distroless image, use an alternate wait strategy",
{ containerId: "containerId" },
],
]);
});

it.for([126, 127])("should error log the distroless image when exit code is %i", async (code) => {
mockContainerExec.mockReturnValueOnce(Promise.resolve({ output: "ERROR 1", exitCode: code }));
mockContainerExec.mockReturnValueOnce(Promise.resolve({ output: "ERROR 2", exitCode: code }));
mockContainerExec.mockReturnValueOnce(Promise.resolve({ output: "ERROR 3", exitCode: code }));

await portCheck.isBound(8080);

expect(mockLogger.error.mock.calls).toEqual([
[
"The HostPortWaitStrategy will not work on a distroless image, use an alternate wait strategy",
{ containerId: "containerId" },
],
]);
});
});
});
45 changes: 23 additions & 22 deletions packages/testcontainers/src/wait-strategies/utils/port-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export class HostPortCheck implements PortCheck {

export class InternalPortCheck implements PortCheck {
private isDistroless = false;
private commandOutputs = new Set<string>();
private readonly commandOutputs = new Set<string>();

constructor(
private readonly client: ContainerRuntimeClient,
Expand All @@ -53,28 +53,29 @@ export class InternalPortCheck implements PortCheck {
);
const isBound = commandResults.some((result) => result.exitCode === 0);

// https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html
// If a command is not found, the child process created to execute it returns a status of 127.
// If a command is found but is not executable, the return status is 126.
const shellExists = commandResults.some((result) => result.exitCode !== 126 && result.exitCode !== 127);
if (!isBound && !shellExists && !this.isDistroless) {
this.isDistroless = true;
log.error(`The HostPortWaitStrategy will not work on a distroless image, use an alternate wait strategy`, {
containerId: this.container.id,
});
}

if (!isBound && log.enabled()) {
const shellExists = commandResults.some((result) => result.exitCode !== 126);
if (!shellExists) {
if (!this.isDistroless) {
this.isDistroless = true;
log.error(`The HostPortWaitStrategy will not work on a distroless image, use an alternate wait strategy`, {
containerId: this.container.id,
});
}
} else {
commandResults
.map((result) => ({ ...result, output: result.output.trim() }))
.filter((result) => result.exitCode !== 126 && result.output.length > 0)
.forEach((result) => {
if (!this.commandOutputs.has(this.commandOutputsKey(result.output))) {
log.trace(`Port check result exit code ${result.exitCode}: ${result.output}`, {
containerId: this.container.id,
});
this.commandOutputs.add(this.commandOutputsKey(result.output));
}
});
}
commandResults
.map((result) => ({ ...result, output: result.output.trim() }))
.filter((result) => result.exitCode !== 126 && result.output.length > 0)
.forEach((result) => {
if (!this.commandOutputs.has(this.commandOutputsKey(result.output))) {
log.trace(`Port check result exit code ${result.exitCode}: ${result.output}`, {
containerId: this.container.id,
});
this.commandOutputs.add(this.commandOutputsKey(result.output));
}
});
}

return isBound;
Expand Down