Skip to content

Include the build output when an image build fails - #1511

Draft
cristianrgreco wants to merge 2 commits into
mainfrom
claude/build-failure-output-cde11c
Draft

cristianrgreco wants to merge 2 commits into
mainfrom
claude/build-failure-output-cde11c

Conversation

@cristianrgreco

@cristianrgreco cristianrgreco commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

When GenericContainer.fromDockerfile(...).build() fails, the error now says why:

Failed to build image: The command '/bin/sh -c cat /does-not-exist' returned a non-zero code: 1

Build output:
Step 1/6 : FROM alpine:3.10
 ---> 19a85ffa0ee1
Step 2/6 : RUN cat /does-not-exist
 ---> Running in f152a2cc5b4e
cat: can't open '/does-not-exist': No such file or directory
  • DockerImageClient.build() reads the build stream into a BuildOutput, which keeps the daemon's error entry and the last 50 non-empty lines of output (ANSI codes stripped), and throws when the daemon reported an error. Only the tail is held in memory.
  • BuildKit puts step output in base64 protobuf moby.buildkit.trace entries (Container build logs aren't very useful because they're encoded #1049). buildkit-trace.ts reads the vertex names and log messages out of them with a small wire-format reader, so no schema or dependency is added. If this is not wanted, deleting that file and its one call site leaves BuildKit errors with the daemon's error line only.
  • GenericContainerBuilder.build() prefixes the failure with Failed to build image: and keeps the original error as cause.
  • The classic builder rejects some builds before streaming (HTTP 400, for example an unknown target stage). That rejection was unhandled and build() never settled. It now rejects, so the skipped should throw an error for a target stage that does not exist test is enabled.
  • Docs: one sentence in docs/features/images.md.

Docker Compose builds are not changed: they run through the docker compose CLI in compose-client.ts. Its error carries the CLI's stderr, which names the failing Dockerfile line and exit code but not the step's own output (the CLI writes that to stdout). That would be a separate change.

Other implementations

  • Java: docker-java's BuildImageResultCallback throws Could not build image: <error> from the stream's error entry, and a bare Could not build image when there is none.
  • Go: jsonmessage.DisplayStream returns the stream's error, wrapped as build image: <error>.
  • .NET: ImageBuildFailedException lists the stream's error entries; its BuildKit path shells out to docker buildx build and puts the CLI's stderr in the exception.
  • Python: docker-py raises BuildError(reason, build_log).

Borrowed: the error entry in the message with a fixed prefix and a bare fallback (Java, Go, .NET), and including build output (.NET BuildKit, Python). The bounded tail is specific to this PR.

Verification

  • npm run format
  • npm run lint
  • npm run knip
  • npm run check-compiles
  • npx vitest run packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts packages/testcontainers/src/container-runtime/clients/image/build-output.test.ts
  • npx vitest run packages/testcontainers/src/generic-container/generic-container.test.ts -t "target stage that does not exist"

Test results

Red, before the implementation change:

× should include the build output when the build fails
× should include the build output when the build fails with buildkit
AssertionError: expected [Function] to throw error matching /^Failed to build image: .*No such fi…/s but got 'Failed to build image'

× should throw an error for a target stage that does not exist
Error: Test timed out in 180000ms.
Unhandled Rejection: Error: (HTTP code 400) unexpected - target stage "invalid" could not be found

Green, after:

Test Files  2 passed (2)
     Tests  15 passed (15)

Test Files  1 passed (1)
     Tests  1 passed | 51 skipped (52)

Not run locally against Podman; the Podman compat build handler streams step output as stream entries and the failure as an error entry, which is the shape this change reads.

Not breaking

  • No public API or type changes; ImageClient.build() still returns Promise<void>.
  • The message still starts with Failed to build image, and the bare message is still thrown when the daemon reports no error but the image is missing.
  • Changes in behaviour, all on paths that already failed: the low-level client.image.build() now rejects when the daemon reports a build error (it used to resolve and leave the caller to notice the missing image), other errors from it are now prefixed with Failed to build image: by GenericContainerBuilder.build() with the original as cause, and pre-stream rejections surface instead of hanging.

Closes #1508

A failed `fromDockerfile(...).build()` threw only "Failed to build
image". The error now carries the daemon's error and the last 50 lines
of build output, for the classic builder and BuildKit. Builds the daemon
rejects before streaming now reject instead of never settling.
@cristianrgreco cristianrgreco added enhancement New feature or request minor Backward compatible functionality labels Oct 12, 2026
…output-cde11c

# Conflicts:
#	packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts
@cristianrgreco
cristianrgreco marked this pull request as draft October 13, 2026 15:55

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request minor Backward compatible functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Include the build output when an image build fails

1 participant