From 9978f407177102aff11b1ebcc32463cb986c306e Mon Sep 17 00:00:00 2001 From: Dean Chen <862469039@qq.com> Date: Tue, 1 Sep 2026 17:28:48 +0500 Subject: [PATCH] build: print the image ID with --quiet when the image is not loaded When buildkitd runs with the containerd worker, the built image does not need loading into the image store, and `nerdctl build -q` printed nothing: the digest was only printed by the load path taken with the OCI worker. Request a buildkit metadata file in quiet mode too, and print the digest from it after a successful build, so that `build -q` consistently outputs the image identifier. Fixes #2015 Signed-off-by: Dean Chen <862469039@qq.com> --- cmd/nerdctl/builder/builder_build_test.go | 33 +++++++++++++++ pkg/cmd/builder/build.go | 32 +++++++++++--- pkg/cmd/builder/build_test.go | 51 +++++++++++++++++++++++ 3 files changed, 110 insertions(+), 6 deletions(-) diff --git a/cmd/nerdctl/builder/builder_build_test.go b/cmd/nerdctl/builder/builder_build_test.go index b7e3a384b54..f42d079fa68 100644 --- a/cmd/nerdctl/builder/builder_build_test.go +++ b/cmd/nerdctl/builder/builder_build_test.go @@ -507,6 +507,39 @@ CMD ["echo", "nerdctl-build-test-string"] testCase.Run(t) } +func TestBuildQuiet(t *testing.T) { + nerdtest.Setup() + + dockerfile := fmt.Sprintf(`FROM %s +CMD ["echo", "nerdctl-build-test-string"] + `, testutil.CommonImage) + + testCase := &test.Case{ + Require: nerdtest.Build, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Setup: func(data test.Data, helpers test.Helpers) { + data.Temp().Save(dockerfile, "Dockerfile") + // Regardless of whether the buildkit worker loads the image into the image store + // or not, `build -q` must print the image identifier on stdout. + // https://github.com/containerd/nerdctl/issues/2015 + imageID := strings.TrimSpace(helpers.Capture("build", "-q", "-t", data.Identifier(), data.Temp().Path())) + assert.Assert(helpers.T(), regexp.MustCompile(`^sha256:[0-9a-f]{64}$`).MatchString(imageID), + "expected `build -q` to output a valid image ID, got %q", imageID) + data.Labels().Set("imageID", imageID) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + // The image ID printed by `build -q` must be usable to run the built image. + return helpers.Command("run", "--rm", data.Labels().Get("imageID")) + }, + + Expected: test.Expects(expect.ExitCodeSuccess, nil, expect.Equals("nerdctl-build-test-string\n")), + } + + testCase.Run(t) +} + func TestBuildWithLabels(t *testing.T) { nerdtest.Setup() diff --git a/pkg/cmd/builder/build.go b/pkg/cmd/builder/build.go index 9cd9ce15d5f..33075308352 100644 --- a/pkg/cmd/builder/build.go +++ b/pkg/cmd/builder/build.go @@ -106,13 +106,29 @@ func Build(ctx context.Context, client *containerd.Client, options types.Builder return err } - if options.IidFile != "" { + if metaFile != "" { id, err := getDigestFromMetaFile(metaFile) if err != nil { - return err - } - if err := filesystem.WriteFile(options.IidFile, []byte(id), 0644); err != nil { - return err + // A missing digest is fatal when the user explicitly asked for an iidfile, but not + // in quiet mode: the requested output may legitimately have no image digest + // (e.g. `--output type=local`). + if options.IidFile != "" { + return err + } + log.L.WithError(err).Debug("failed to get the image digest from the build metadata file") + } else { + if options.IidFile != "" { + if err := filesystem.WriteFile(options.IidFile, []byte(id), 0644); err != nil { + return err + } + } + // In quiet mode, the digest of a loaded image is printed by loadImage. + // When the image does not need loading (e.g. buildkitd with the containerd worker), + // print the digest here instead, so that `nerdctl build -q` outputs the image ID. + // https://github.com/containerd/nerdctl/issues/2015 + if options.Quiet && !needsLoading { + fmt.Fprintln(options.Stdout, id) + } } } @@ -452,7 +468,11 @@ func generateBuildctlArgs(ctx context.Context, client *containerd.Client, option log.L.Warn("ignoring deprecated flag: '--rm=false'") } - if options.IidFile != "" { + // The metadata file is needed to obtain the image digest: when --iidfile is passed, + // and in quiet mode when the image is not loaded (e.g. buildkitd with the containerd worker), + // in which case the digest is not printed by the load path. + // https://github.com/containerd/nerdctl/issues/2015 + if options.IidFile != "" || (options.Quiet && !needsLoading) { file, err := os.CreateTemp("", "buildkit-meta-*") if err != nil { return "", nil, false, "", nil, cleanup, err diff --git a/pkg/cmd/builder/build_test.go b/pkg/cmd/builder/build_test.go index 6566fdd46d4..df2e82ed492 100644 --- a/pkg/cmd/builder/build_test.go +++ b/pkg/cmd/builder/build_test.go @@ -18,6 +18,7 @@ package builder import ( "fmt" + "os" "path/filepath" "runtime" "testing" @@ -281,3 +282,53 @@ func TestGetEffectiveSourcePolicyFile(t *testing.T) { }) } } + +func TestGetDigestFromMetaFile(t *testing.T) { + t.Parallel() + + const digest = "sha256:e2c8f34a2e73f9e11c93de402b9797adf95bab1e5ffb845b2cbe18f0e19dd0f1" + + tests := []struct { + name string + content string + expected string + wantErr bool + }{ + { + name: "digest present", + content: fmt.Sprintf(`{"containerimage.digest": %q}`, digest), + expected: digest, + }, + { + name: "digest missing", + content: `{"containerimage.config.digest": "whatever"}`, + wantErr: true, + }, + { + name: "invalid json", + content: `{`, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "meta.json") + assert.NilError(t, os.WriteFile(path, []byte(tc.content), 0o600)) + + id, err := getDigestFromMetaFile(path) + if tc.wantErr { + assert.Assert(t, err != nil) + } else { + assert.NilError(t, err) + assert.Equal(t, id, tc.expected) + } + + // The metadata file is a temporary file, and must be removed once read. + _, err = os.Stat(path) + assert.Assert(t, os.IsNotExist(err)) + }) + } +}