From b85c7c99178ee65767c08a34c11324d555d77232 Mon Sep 17 00:00:00 2001 From: Gabriel Harnagea Date: Wed, 2 Sep 2026 23:16:56 +0200 Subject: [PATCH] cli/command/image: trim iidfile image ID and fail clearly when missing When building with `--iidfile`, the CLI takes the resulting image ID either from the daemon's "aux" message or, in quiet mode, from the build output, then writes it verbatim to the file. Two problems with that: - The quiet-mode value can carry surrounding whitespace/newlines, which then end up in the iidfile and get misread by downstream tooling. - An all-whitespace value passed the previous `imageID == ""` guard and was written as a bogus (effectively empty) file. Trim the value before using it and treat an empty result as "no image ID", returning a clear error instead of writing a broken file. This is the situation reported in docker/cli#2971, where parallel builds sharing an iidfile (or older daemons) could leave the CLI without an ID. Signed-off-by: Gabriel Harnagea --- cli/command/image/build.go | 12 ++++++--- cli/command/image/build_test.go | 44 ++++++++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/cli/command/image/build.go b/cli/command/image/build.go index db33e0e7c64d..2e2a52a50e5d 100644 --- a/cli/command/image/build.go +++ b/cli/command/image/build.go @@ -386,10 +386,16 @@ func runBuild(ctx context.Context, dockerCli command.Cli, options buildOptions) } if options.imageIDFile != "" { - if imageID == "" { - return fmt.Errorf("server did not provide an image ID. Cannot write %s", options.imageIDFile) + // The daemon reports the resulting image ID either through an "aux" + // message or (in quiet mode) as the build output. A missing ID has + // been observed with older daemons and with parallel builds sharing + // the same iidfile (docker/cli#2971); fail with a clear error instead + // of writing an empty file that downstream tooling would misread. + id := strings.TrimSpace(imageID) + if id == "" { + return fmt.Errorf("server did not provide an image ID; cannot write %s", options.imageIDFile) } - if err := os.WriteFile(options.imageIDFile, []byte(imageID), 0o666); err != nil { + if err := os.WriteFile(options.imageIDFile, []byte(id), 0o666); err != nil { return err } } diff --git a/cli/command/image/build_test.go b/cli/command/image/build_test.go index 88d94d6cdec4..fcc3601af7c1 100644 --- a/cli/command/image/build_test.go +++ b/cli/command/image/build_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "sort" + "strings" "testing" "github.com/docker/cli/cli/streams" @@ -150,6 +151,41 @@ func TestRunBuildFromLocalGitHubDir(t *testing.T) { assert.NilError(t, err) } +func TestRunBuildWithIidFileWritesImageID(t *testing.T) { + t.Setenv("DOCKER_BUILDKIT", "0") + const imageID = "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + fakeBuild := newFakeBuild() + fakeBuild.response = `{"aux":{"ID":"` + imageID + `"}}` + "\n" + cli := test.NewFakeCli(&fakeClient{imageBuildFunc: fakeBuild.build}) + dir := fs.NewDir(t, t.Name(), fs.WithFile("Dockerfile", "FROM scratch\n")) + defer dir.Remove() + + options := newBuildOptions() + options.context = dir.Path() + options.imageIDFile = filepath.Join(t.TempDir(), "iidfile") + assert.NilError(t, runBuild(context.TODO(), cli, options)) + + contents, err := os.ReadFile(options.imageIDFile) + assert.NilError(t, err) + assert.Equal(t, string(contents), imageID) +} + +func TestRunBuildWithoutImageIDReturnsError(t *testing.T) { + t.Setenv("DOCKER_BUILDKIT", "0") + fakeBuild := newFakeBuild() + cli := test.NewFakeCli(&fakeClient{imageBuildFunc: fakeBuild.build}) + dir := fs.NewDir(t, t.Name(), fs.WithFile("Dockerfile", "FROM scratch\n")) + defer dir.Remove() + + options := newBuildOptions() + options.context = dir.Path() + options.imageIDFile = filepath.Join(t.TempDir(), "iidfile") + err := runBuild(context.TODO(), cli, options) + assert.ErrorContains(t, err, "did not provide an image ID") + _, statErr := os.Stat(options.imageIDFile) + assert.Assert(t, os.IsNotExist(statErr)) +} + func TestRunBuildWithSymlinkedContext(t *testing.T) { t.Setenv("DOCKER_BUILDKIT", "0") dockerfile := ` @@ -173,8 +209,9 @@ RUN echo hello world } type fakeBuild struct { - context *tar.Reader - options client.ImageBuildOptions + context *tar.Reader + options client.ImageBuildOptions + response string } func newFakeBuild() *fakeBuild { @@ -184,8 +221,7 @@ func newFakeBuild() *fakeBuild { func (f *fakeBuild) build(_ context.Context, buildContext io.Reader, options client.ImageBuildOptions) (client.ImageBuildResult, error) { f.context = tar.NewReader(buildContext) f.options = options - body := new(bytes.Buffer) - return client.ImageBuildResult{Body: io.NopCloser(body)}, nil + return client.ImageBuildResult{Body: io.NopCloser(strings.NewReader(f.response))}, nil } func (f *fakeBuild) headers(t *testing.T) []*tar.Header {