Browse code

api: image inspect: add back fields that did not omitempty

commit 4dc961d0e922381b8cd5e05223f172f3145c7494 removed deprecated
fields from the image inspect response for API v1.50 and up. As
part of that change, it changed the type used for the Config field
to use the docker image spect structs, which embeds the OCI image
spec structs.

While the OCI image spect struct contains the same fields as we
used before, those fields also have "omitempty" set, which means
they are now omitted when empty.

We should probably consider deprecating that behavior in the API,
and call out that these fields are omitted if not set, but in the
meantime, we can add them back with their default (zero) value.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>

Sebastiaan van Stijn authored on 2025/06/03 22:39:32
Showing 4 changed files
... ...
@@ -15,7 +15,6 @@ import (
15 15
 	"github.com/docker/docker/api"
16 16
 	"github.com/docker/docker/api/server/httputils"
17 17
 	"github.com/docker/docker/api/types/backend"
18
-	"github.com/docker/docker/api/types/container"
19 18
 	"github.com/docker/docker/api/types/filters"
20 19
 	imagetypes "github.com/docker/docker/api/types/image"
21 20
 	"github.com/docker/docker/api/types/registry"
... ...
@@ -27,8 +26,6 @@ import (
27 27
 	"github.com/docker/docker/pkg/ioutils"
28 28
 	"github.com/docker/docker/pkg/progress"
29 29
 	"github.com/docker/docker/pkg/streamformatter"
30
-	"github.com/docker/go-connections/nat"
31
-	dockerspec "github.com/moby/docker-image-spec/specs-go/v1"
32 30
 	"github.com/opencontainers/go-digest"
33 31
 	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
34 32
 	"github.com/pkg/errors"
... ...
@@ -370,7 +367,7 @@ func (ir *imageRouter) getImagesByName(ctx context.Context, w http.ResponseWrite
370 370
 		return errdefs.InvalidParameter(errors.New("conflicting options: manifests and platform options cannot both be set"))
371 371
 	}
372 372
 
373
-	imageInspect, err := ir.backend.ImageInspect(ctx, vars["name"], backend.ImageInspectOpts{
373
+	resp, err := ir.backend.ImageInspect(ctx, vars["name"], backend.ImageInspectOpts{
374 374
 		Manifests: manifests,
375 375
 		Platform:  platform,
376 376
 	})
... ...
@@ -378,6 +375,14 @@ func (ir *imageRouter) getImagesByName(ctx context.Context, w http.ResponseWrite
378 378
 		return err
379 379
 	}
380 380
 
381
+	// inspectResponse preserves fields in the response that have an
382
+	// "omitempty" in the OCI spec, but didn't omit such fields in
383
+	// legacy responses before API v1.50.
384
+	imageInspect := &inspectCompatResponse{
385
+		InspectResponse: resp,
386
+		legacyConfig:    legacyConfigFields["current"],
387
+	}
388
+
381 389
 	// Make sure we output empty arrays instead of nil. While Go nil slice is functionally equivalent to an empty slice,
382 390
 	// it matters for the JSON representation.
383 391
 	if imageInspect.RepoTags == nil {
... ...
@@ -405,14 +410,7 @@ func (ir *imageRouter) getImagesByName(ctx context.Context, w http.ResponseWrite
405 405
 		imageInspect.Descriptor = nil
406 406
 	}
407 407
 	if versions.LessThan(version, "1.50") {
408
-		type imageInspectLegacy struct {
409
-			imagetypes.InspectResponse
410
-			LegacyConfig *container.Config `json:"Config"`
411
-		}
412
-		return httputils.WriteJSON(w, http.StatusOK, imageInspectLegacy{
413
-			InspectResponse: *imageInspect,
414
-			LegacyConfig:    dockerOCIImageConfigToContainerConfig(*imageInspect.Config),
415
-		})
408
+		imageInspect.legacyConfig = legacyConfigFields["v1.49"]
416 409
 	}
417 410
 
418 411
 	return httputils.WriteJSON(w, http.StatusOK, imageInspect)
... ...
@@ -598,27 +596,3 @@ func validateRepoName(name reference.Named) error {
598 598
 	}
599 599
 	return nil
600 600
 }
601
-
602
-// FIXME(thaJeztah): this is a copy of dockerOCIImageConfigToContainerConfig in daemon/containerd: https://github.com/moby/moby/blob/6b617699c500522aa6526cfcae4558333911b11f/daemon/containerd/imagespec.go#L107-L128
603
-func dockerOCIImageConfigToContainerConfig(cfg dockerspec.DockerOCIImageConfig) *container.Config {
604
-	exposedPorts := make(nat.PortSet, len(cfg.ExposedPorts))
605
-	for k, v := range cfg.ExposedPorts {
606
-		exposedPorts[nat.Port(k)] = v
607
-	}
608
-
609
-	return &container.Config{
610
-		Entrypoint:   cfg.Entrypoint,
611
-		Env:          cfg.Env,
612
-		Cmd:          cfg.Cmd,
613
-		User:         cfg.User,
614
-		WorkingDir:   cfg.WorkingDir,
615
-		ExposedPorts: exposedPorts,
616
-		Volumes:      cfg.Volumes,
617
-		Labels:       cfg.Labels,
618
-		ArgsEscaped:  cfg.ArgsEscaped, //nolint:staticcheck // Ignore SA1019. Need to keep it in image.
619
-		StopSignal:   cfg.StopSignal,
620
-		Healthcheck:  cfg.Healthcheck,
621
-		OnBuild:      cfg.OnBuild,
622
-		Shell:        cfg.Shell,
623
-	}
624
-}
625 601
new file mode 100644
... ...
@@ -0,0 +1,88 @@
0
+// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
1
+//go:build go1.23
2
+
3
+package image
4
+
5
+import (
6
+	"encoding/json"
7
+	"maps"
8
+
9
+	"github.com/docker/docker/api/types/image"
10
+)
11
+
12
+// legacyConfigFields defines legacy image-config fields to include in
13
+// API responses on older API versions.
14
+var legacyConfigFields = map[string]map[string]any{
15
+	// Legacy fields for API v1.49 and lower. These fields are deprecated
16
+	// and omitted in newer API versions; see https://github.com/moby/moby/pull/48457
17
+	"v1.49": {
18
+		"AttachStderr": false,
19
+		"AttachStdin":  false,
20
+		"AttachStdout": false,
21
+		"Cmd":          nil,
22
+		"Domainname":   "",
23
+		"Entrypoint":   nil,
24
+		"Env":          nil,
25
+		"Hostname":     "",
26
+		"Image":        "",
27
+		"Labels":       nil,
28
+		"OnBuild":      nil,
29
+		"OpenStdin":    false,
30
+		"StdinOnce":    false,
31
+		"Tty":          false,
32
+		"User":         "",
33
+		"Volumes":      nil,
34
+		"WorkingDir":   "",
35
+	},
36
+	// Legacy fields for current API versions (v1.50 and up). These fields
37
+	// did not have an "omitempty" and were always included in the response,
38
+	// even if not set; see https://github.com/moby/moby/issues/50134
39
+	"current": {
40
+		"Cmd":        nil,
41
+		"Entrypoint": nil,
42
+		"Env":        nil,
43
+		"Labels":     nil,
44
+		"OnBuild":    nil,
45
+		"User":       "",
46
+		"Volumes":    nil,
47
+		"WorkingDir": "",
48
+	},
49
+}
50
+
51
+// inspectCompatResponse is a wrapper around [image.InspectResponse] with a
52
+// custom marshal function for legacy [api/types/container.Config} fields
53
+// that have been removed, or did not have omitempty.
54
+type inspectCompatResponse struct {
55
+	*image.InspectResponse
56
+	legacyConfig map[string]any
57
+}
58
+
59
+// MarshalJSON implements a custom marshaler to include legacy fields
60
+// in API responses.
61
+func (ir *inspectCompatResponse) MarshalJSON() ([]byte, error) {
62
+	type tmp *image.InspectResponse
63
+	base, err := json.Marshal((tmp)(ir.InspectResponse))
64
+	if err != nil {
65
+		return nil, err
66
+	}
67
+	if len(ir.legacyConfig) == 0 {
68
+		return base, nil
69
+	}
70
+
71
+	type resp struct {
72
+		*image.InspectResponse
73
+		Config map[string]any
74
+	}
75
+
76
+	var merged resp
77
+	err = json.Unmarshal(base, &merged)
78
+	if err != nil {
79
+		return base, nil
80
+	}
81
+
82
+	// prevent mutating legacyConfigFields.
83
+	cfg := maps.Clone(ir.legacyConfig)
84
+	maps.Copy(cfg, merged.Config)
85
+	merged.Config = cfg
86
+	return json.Marshal(merged)
87
+}
0 88
new file mode 100644
... ...
@@ -0,0 +1,74 @@
0
+package image
1
+
2
+import (
3
+	"encoding/json"
4
+	"testing"
5
+
6
+	"github.com/docker/docker/api/types/image"
7
+	dockerspec "github.com/moby/docker-image-spec/specs-go/v1"
8
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
9
+	"gotest.tools/v3/assert"
10
+	is "gotest.tools/v3/assert/cmp"
11
+)
12
+
13
+func TestInspectResponse(t *testing.T) {
14
+	tests := []struct {
15
+		doc          string
16
+		cfg          *ocispec.ImageConfig
17
+		legacyConfig map[string]any
18
+		expected     string
19
+	}{
20
+		{
21
+			doc:      "empty",
22
+			expected: `null`,
23
+		},
24
+		{
25
+			doc: "no legacy config",
26
+			cfg: &ocispec.ImageConfig{
27
+				Cmd:        []string{"/bin/sh"},
28
+				StopSignal: "SIGQUIT",
29
+			},
30
+			expected: `{"Cmd":["/bin/sh"],"StopSignal":"SIGQUIT"}`,
31
+		},
32
+		{
33
+			doc: "api < v1.50",
34
+			cfg: &ocispec.ImageConfig{
35
+				Cmd:        []string{"/bin/sh"},
36
+				StopSignal: "SIGQUIT",
37
+			},
38
+			legacyConfig: legacyConfigFields["v1.49"],
39
+			expected:     `{"AttachStderr":false,"AttachStdin":false,"AttachStdout":false,"Cmd":["/bin/sh"],"Domainname":"","Entrypoint":null,"Env":null,"Hostname":"","Image":"","Labels":null,"OnBuild":null,"OpenStdin":false,"StdinOnce":false,"StopSignal":"SIGQUIT","Tty":false,"User":"","Volumes":null,"WorkingDir":""}`,
40
+		},
41
+		{
42
+			doc: "api >= v1.50",
43
+			cfg: &ocispec.ImageConfig{
44
+				Cmd:        []string{"/bin/sh"},
45
+				StopSignal: "SIGQUIT",
46
+			},
47
+			legacyConfig: legacyConfigFields["current"],
48
+			expected:     `{"Cmd":["/bin/sh"],"Entrypoint":null,"Env":null,"Labels":null,"OnBuild":null,"StopSignal":"SIGQUIT","User":"","Volumes":null,"WorkingDir":""}`,
49
+		},
50
+	}
51
+	for _, tc := range tests {
52
+		t.Run(tc.doc, func(t *testing.T) {
53
+			imgInspect := &image.InspectResponse{}
54
+			if tc.cfg != nil {
55
+				// Verify that fields that are set override the legacy values,
56
+				// or appended if not part of the legacy values.
57
+				imgInspect.Config = &dockerspec.DockerOCIImageConfig{
58
+					ImageConfig: *tc.cfg,
59
+				}
60
+			}
61
+			out, err := json.Marshal(&inspectCompatResponse{
62
+				InspectResponse: imgInspect,
63
+				legacyConfig:    tc.legacyConfig,
64
+			})
65
+			assert.NilError(t, err)
66
+
67
+			var outMap struct{ Config json.RawMessage }
68
+			err = json.Unmarshal(out, &outMap)
69
+			assert.NilError(t, err)
70
+			assert.Check(t, is.Equal(string(outMap.Config), tc.expected))
71
+		})
72
+	}
73
+}
... ...
@@ -3138,13 +3138,7 @@ func (s *DockerCLIBuildSuite) TestBuildClearCmd(c *testing.T) {
3138 3138
    CMD []`))
3139 3139
 
3140 3140
 	cmd := inspectFieldJSON(c, name, "Config.Cmd")
3141
-	// OCI types specify `omitempty` JSON annotation which doesn't serialize
3142
-	// empty arrays and the Cmd will not be present at all.
3143
-	if testEnv.UsingSnapshotter() {
3144
-		assert.Check(c, is.Equal(cmd, "null"))
3145
-	} else {
3146
-		assert.Check(c, is.Equal(cmd, "[]"))
3147
-	}
3141
+	assert.Check(c, is.Equal(cmd, "null"))
3148 3142
 }
3149 3143
 
3150 3144
 func (s *DockerCLIBuildSuite) TestBuildEmptyCmd(c *testing.T) {