Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 10 additions & 36 deletions api/server/router/image/image_routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import (
"github.com/docker/docker/api"
"github.com/docker/docker/api/server/httputils"
"github.com/docker/docker/api/types/backend"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
imagetypes "github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/registry"
Expand All @@ -27,8 +26,6 @@ import (
"github.com/docker/docker/pkg/ioutils"
"github.com/docker/docker/pkg/progress"
"github.com/docker/docker/pkg/streamformatter"
"github.com/docker/go-connections/nat"
dockerspec "github.com/moby/docker-image-spec/specs-go/v1"
"github.com/opencontainers/go-digest"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/pkg/errors"
Expand Down Expand Up @@ -370,14 +367,22 @@ func (ir *imageRouter) getImagesByName(ctx context.Context, w http.ResponseWrite
return errdefs.InvalidParameter(errors.New("conflicting options: manifests and platform options cannot both be set"))
}

imageInspect, err := ir.backend.ImageInspect(ctx, vars["name"], backend.ImageInspectOpts{
resp, err := ir.backend.ImageInspect(ctx, vars["name"], backend.ImageInspectOpts{
Manifests: manifests,
Platform: platform,
})
if err != nil {
return err
}

// inspectResponse preserves fields in the response that have an
// "omitempty" in the OCI spec, but didn't omit such fields in
// legacy responses before API v1.50.
imageInspect := &inspectCompatResponse{
InspectResponse: resp,
legacyConfig: legacyConfigFields["current"],
}
Comment on lines +381 to +384
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW; I started to look if adding a constructor for this would be cleaner, and possibly it could be, but rewriting took me down the rabbit hole, so I'm gonna leave that for a future refactor; possibly combining all the version-related changes below 🤔


// Make sure we output empty arrays instead of nil. While Go nil slice is functionally equivalent to an empty slice,
// it matters for the JSON representation.
if imageInspect.RepoTags == nil {
Expand Down Expand Up @@ -405,14 +410,7 @@ func (ir *imageRouter) getImagesByName(ctx context.Context, w http.ResponseWrite
imageInspect.Descriptor = nil
}
if versions.LessThan(version, "1.50") {
type imageInspectLegacy struct {
imagetypes.InspectResponse
LegacyConfig *container.Config `json:"Config"`
}
return httputils.WriteJSON(w, http.StatusOK, imageInspectLegacy{
InspectResponse: *imageInspect,
LegacyConfig: dockerOCIImageConfigToContainerConfig(*imageInspect.Config),
})
imageInspect.legacyConfig = legacyConfigFields["v1.49"]
}

return httputils.WriteJSON(w, http.StatusOK, imageInspect)
Expand Down Expand Up @@ -598,27 +596,3 @@ func validateRepoName(name reference.Named) error {
}
return nil
}

// FIXME(thaJeztah): this is a copy of dockerOCIImageConfigToContainerConfig in daemon/containerd: https://github.com/moby/moby/blob/6b617699c500522aa6526cfcae4558333911b11f/daemon/containerd/imagespec.go#L107-L128
func dockerOCIImageConfigToContainerConfig(cfg dockerspec.DockerOCIImageConfig) *container.Config {
exposedPorts := make(nat.PortSet, len(cfg.ExposedPorts))
for k, v := range cfg.ExposedPorts {
exposedPorts[nat.Port(k)] = v
}

return &container.Config{
Entrypoint: cfg.Entrypoint,
Env: cfg.Env,
Cmd: cfg.Cmd,
User: cfg.User,
WorkingDir: cfg.WorkingDir,
ExposedPorts: exposedPorts,
Volumes: cfg.Volumes,
Labels: cfg.Labels,
ArgsEscaped: cfg.ArgsEscaped, //nolint:staticcheck // Ignore SA1019. Need to keep it in image.
StopSignal: cfg.StopSignal,
Healthcheck: cfg.Healthcheck,
OnBuild: cfg.OnBuild,
Shell: cfg.Shell,
}
}
88 changes: 88 additions & 0 deletions api/server/router/image/inspect_response.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
//go:build go1.23

package image

import (
"encoding/json"
"maps"

"github.com/docker/docker/api/types/image"
)

// legacyConfigFields defines legacy image-config fields to include in
// API responses on older API versions.
var legacyConfigFields = map[string]map[string]any{
// Legacy fields for API v1.49 and lower. These fields are deprecated
// and omitted in newer API versions; see https://github.com/moby/moby/pull/48457
"v1.49": {
"AttachStderr": false,
"AttachStdin": false,
"AttachStdout": false,
"Cmd": nil,
"Domainname": "",
"Entrypoint": nil,
"Env": nil,
"Hostname": "",
"Image": "",
"Labels": nil,
"OnBuild": nil,
"OpenStdin": false,
"StdinOnce": false,
"Tty": false,
"User": "",
"Volumes": nil,
"WorkingDir": "",
},
// Legacy fields for current API versions (v1.50 and up). These fields
// did not have an "omitempty" and were always included in the response,
// even if not set; see https://github.com/moby/moby/issues/50134
"current": {
"Cmd": nil,
"Entrypoint": nil,
"Env": nil,
"Labels": nil,
"OnBuild": nil,
"User": "",
"Volumes": nil,
"WorkingDir": "",
},
Comment on lines +37 to +49
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can avoid this one and just marshal the base InspectResponse instead?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These were the ones that had an omit empty, so were not included #50134

So I think using the base would then result in them disappearing again.

But for api v1.51 we can consider doing so, but we'd have to deprecate that behavior first (can do that in a separate PR)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right...

I think what confused me is that the caller is responsible for passing the correct value of legacyConfigFields.

Could we just pass the API version the response should be adapted to?

So

imageInspect := &inspectCompatResponse{
		InspectResponse: resp,
}

would mean the "current"/"latest" and

imageInspect := &inspectCompatResponse{
		InspectResponse: resp,
        version: "1.49"
}

would mean the 1.49 compatible response..

Although that's probably what you meant in https://github.com/moby/moby/pull/50135/files#r2126024062?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw, I think it would be slightly easier to understand if we declared a copy-pasted, unexported versions of the "legacy" Config struct and just explicitly filled and marshalled them inside inspectCompatResponse.MarshalJSON.
While more verbose, I think it would be much clearer and easier to understand.

Just a thought though, not a blocker

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we just pass the API version the response should be adapted to?

Yeah, that's something I was considering, and mostly had written, then threw it away thinking to KISS for now. I can still have a look at that though.

I think it would be slightly easier to understand if we declared a copy-pasted, unexported versions of the "legacy" Config struct

Ah, gotcha. Hm.. yes, possibly could work. My goal at least was to avoid having to depend on legacy types or fields if that meant we'd have to continue maintaining the fields (as "deprecated") purely for the old responses (but with empty / zero values).

Having a (non-exported) copy of such structs purely for that purpose is indeed slightly better; what I slightly tried to avoid though was to (in some cases) that potentially requiring to also use legacy indirect types, such as the nat.Port mapping in the code we had;

exposedPorts := make(nat.PortSet, len(cfg.ExposedPorts))
for k, v := range cfg.ExposedPorts {
exposedPorts[nat.Port(k)] = v
}
return &container.Config{

I recall we used to have these "versioned" legacy types, which I guess is a bit of that approach; some of those started to become quite involved though;
https://github.com/moby/moby/blob/v19.03.0/api/types/versions/v1p19/types.go
https://github.com/moby/moby/blob/v19.03.0/api/types/versions/v1p20/types.go
https://github.com/moby/moby/blob/v19.03.0/daemon/inspect.go#L22-L30

}

// inspectCompatResponse is a wrapper around [image.InspectResponse] with a
// custom marshal function for legacy [api/types/container.Config} fields
// that have been removed, or did not have omitempty.
type inspectCompatResponse struct {
*image.InspectResponse
legacyConfig map[string]any
}

// MarshalJSON implements a custom marshaler to include legacy fields
// in API responses.
func (ir *inspectCompatResponse) MarshalJSON() ([]byte, error) {
type tmp *image.InspectResponse
base, err := json.Marshal((tmp)(ir.InspectResponse))
if err != nil {
return nil, err
}
if len(ir.legacyConfig) == 0 {
return base, nil
}

type resp struct {
*image.InspectResponse
Config map[string]any
}

var merged resp
err = json.Unmarshal(base, &merged)
if err != nil {
return base, nil
}

// prevent mutating legacyConfigFields.
cfg := maps.Clone(ir.legacyConfig)
Comment on lines +83 to +84
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was the problem; I added the legacyConfigFields the router assigns legacyConfig from that, and we didn't de-reference.

maps.Copy(cfg, merged.Config)
merged.Config = cfg
return json.Marshal(merged)
}
74 changes: 74 additions & 0 deletions api/server/router/image/inspect_response_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package image

import (
"encoding/json"
"testing"

"github.com/docker/docker/api/types/image"
dockerspec "github.com/moby/docker-image-spec/specs-go/v1"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
)

func TestInspectResponse(t *testing.T) {
tests := []struct {
doc string
cfg *ocispec.ImageConfig
legacyConfig map[string]any
expected string
}{
{
doc: "empty",
expected: `null`,
},
{
doc: "no legacy config",
cfg: &ocispec.ImageConfig{
Cmd: []string{"/bin/sh"},
StopSignal: "SIGQUIT",
},
expected: `{"Cmd":["/bin/sh"],"StopSignal":"SIGQUIT"}`,
},
{
doc: "api < v1.50",
cfg: &ocispec.ImageConfig{
Cmd: []string{"/bin/sh"},
StopSignal: "SIGQUIT",
},
legacyConfig: legacyConfigFields["v1.49"],
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":""}`,
},
{
doc: "api >= v1.50",
cfg: &ocispec.ImageConfig{
Cmd: []string{"/bin/sh"},
StopSignal: "SIGQUIT",
},
legacyConfig: legacyConfigFields["current"],
expected: `{"Cmd":["/bin/sh"],"Entrypoint":null,"Env":null,"Labels":null,"OnBuild":null,"StopSignal":"SIGQUIT","User":"","Volumes":null,"WorkingDir":""}`,
},
}
for _, tc := range tests {
t.Run(tc.doc, func(t *testing.T) {
imgInspect := &image.InspectResponse{}
if tc.cfg != nil {
// Verify that fields that are set override the legacy values,
// or appended if not part of the legacy values.
imgInspect.Config = &dockerspec.DockerOCIImageConfig{
ImageConfig: *tc.cfg,
}
}
out, err := json.Marshal(&inspectCompatResponse{
InspectResponse: imgInspect,
legacyConfig: tc.legacyConfig,
})
assert.NilError(t, err)

var outMap struct{ Config json.RawMessage }
err = json.Unmarshal(out, &outMap)
assert.NilError(t, err)
assert.Check(t, is.Equal(string(outMap.Config), tc.expected))
})
}
}
8 changes: 1 addition & 7 deletions integration-cli/docker_cli_build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3138,13 +3138,7 @@ func (s *DockerCLIBuildSuite) TestBuildClearCmd(c *testing.T) {
CMD []`))

cmd := inspectFieldJSON(c, name, "Config.Cmd")
// OCI types specify `omitempty` JSON annotation which doesn't serialize
// empty arrays and the Cmd will not be present at all.
if testEnv.UsingSnapshotter() {
assert.Check(c, is.Equal(cmd, "null"))
} else {
assert.Check(c, is.Equal(cmd, "[]"))
}
assert.Check(c, is.Equal(cmd, "null"))
}

func (s *DockerCLIBuildSuite) TestBuildEmptyCmd(c *testing.T) {
Expand Down
Loading