diff --git a/packages/testcontainers/fixtures/docker/docker-no-shell/Dockerfile b/packages/testcontainers/fixtures/docker/docker-no-shell/Dockerfile new file mode 100644 index 000000000..99dc67ab2 --- /dev/null +++ b/packages/testcontainers/fixtures/docker/docker-no-shell/Dockerfile @@ -0,0 +1,11 @@ +FROM node AS build +WORKDIR /app +RUN npm init -y \ + && npm install express@4.16.4 +COPY index.js . + +FROM gcr.io/distroless/nodejs20-debian12 AS app +EXPOSE 8080 +COPY --from=build /app /app +WORKDIR /app +CMD ["index.js"] diff --git a/packages/testcontainers/fixtures/docker/docker-no-shell/index.js b/packages/testcontainers/fixtures/docker/docker-no-shell/index.js new file mode 100644 index 000000000..ae93b36a4 --- /dev/null +++ b/packages/testcontainers/fixtures/docker/docker-no-shell/index.js @@ -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}`)); diff --git a/packages/testcontainers/src/wait-strategies/utils/port-check.test.ts b/packages/testcontainers/src/wait-strategies/utils/port-check.test.ts index 00df3ccc0..54808a760 100644 --- a/packages/testcontainers/src/wait-strategies/utils/port-check.test.ts +++ b/packages/testcontainers/src/wait-strategies/utils/port-check.test.ts @@ -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" }, + ], + ]); + }); }); }); diff --git a/packages/testcontainers/src/wait-strategies/utils/port-check.ts b/packages/testcontainers/src/wait-strategies/utils/port-check.ts index b43f8bee4..0969bb5fb 100644 --- a/packages/testcontainers/src/wait-strategies/utils/port-check.ts +++ b/packages/testcontainers/src/wait-strategies/utils/port-check.ts @@ -33,7 +33,7 @@ export class HostPortCheck implements PortCheck { export class InternalPortCheck implements PortCheck { private isDistroless = false; - private commandOutputs = new Set(); + private readonly commandOutputs = new Set(); constructor( private readonly client: ContainerRuntimeClient, @@ -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;