From 7b881e15748291107434add66ac684d715418c85 Mon Sep 17 00:00:00 2001 From: Mayur Das Date: Sat, 25 Jul 2026 14:42:12 +0530 Subject: [PATCH] feat(image): support --change on import Apply Dockerfile-style instructions to the config of the image created by `nerdctl import`, matching `docker import --change`. Supported instructions are the ones representable in the OCI image config: CMD, ENTRYPOINT, ENV, EXPOSE, LABEL, USER, VOLUME, WORKDIR, STOPSIGNAL. HEALTHCHECK and ONBUILD have no place in an OCI config and are rejected, where docker accepts them into its own config schema; SHELL is rejected by docker too. Instructions are parsed with BuildKit's Dockerfile parser rather than one of our own, so --change takes the same syntax as build. The type switch that writes an instruction into the config is the allow-list: RUN, COPY, FROM and the rest have nothing to write and are rejected, as is a change holding more than one instruction. --change applies to a filesystem (rootfs) import, which builds a fresh config; it is rejected for a standard image archive that already carries its own config. Part of #3867 Signed-off-by: Mayur Das --- cmd/nerdctl/image/image_import.go | 6 + cmd/nerdctl/image/image_import_linux_test.go | 64 ++++++ docs/command-reference.md | 3 +- go.mod | 8 +- go.sum | 20 +- pkg/api/types/import_types.go | 3 + pkg/cmd/image/import.go | 17 ++ pkg/cmd/image/import_change.go | 214 +++++++++++++++++++ pkg/cmd/image/import_change_test.go | 151 +++++++++++++ 9 files changed, 476 insertions(+), 10 deletions(-) create mode 100644 pkg/cmd/image/import_change.go create mode 100644 pkg/cmd/image/import_change_test.go diff --git a/cmd/nerdctl/image/image_import.go b/cmd/nerdctl/image/image_import.go index 555bbcf7e05..97ecc9d1a51 100644 --- a/cmd/nerdctl/image/image_import.go +++ b/cmd/nerdctl/image/image_import.go @@ -45,6 +45,7 @@ func ImportCommand() *cobra.Command { cmd.Flags().StringP("message", "m", "", "Set commit message for imported image") cmd.Flags().String("platform", "", "Set platform for imported image (e.g., linux/amd64)") + cmd.Flags().StringArrayP("change", "c", nil, "Apply Dockerfile instruction to the created image") return cmd } @@ -61,6 +62,10 @@ func importOptions(cmd *cobra.Command, args []string) (types.ImageImportOptions, if err != nil { return types.ImageImportOptions{}, err } + changes, err := cmd.Flags().GetStringArray("change") + if err != nil { + return types.ImageImportOptions{}, err + } var reference string if len(args) > 1 { reference = args[1] @@ -97,6 +102,7 @@ func importOptions(cmd *cobra.Command, args []string) (types.ImageImportOptions, Reference: reference, Message: message, Platform: platform, + Changes: changes, }, nil } diff --git a/cmd/nerdctl/image/image_import_linux_test.go b/cmd/nerdctl/image/image_import_linux_test.go index 7052c101a8e..71db760985c 100644 --- a/cmd/nerdctl/image/image_import_linux_test.go +++ b/cmd/nerdctl/image/image_import_linux_test.go @@ -23,6 +23,7 @@ import ( "net/http" "os" "path/filepath" + "slices" "strings" "testing" @@ -45,6 +46,20 @@ func minimalRootfsTar(t *testing.T) *bytes.Buffer { return buf } +// minimalImageArchiveTar returns a tar that looks like a standard image archive +// (it carries a manifest.json), used to exercise the --change rejection path. +func minimalImageArchiveTar(t *testing.T) *bytes.Buffer { + t.Helper() + buf := new(bytes.Buffer) + tw := tar.NewWriter(buf) + content := []byte("[]") + assert.NilError(t, tw.WriteHeader(&tar.Header{Name: "manifest.json", Size: int64(len(content)), Mode: 0644})) + _, err := tw.Write(content) + assert.NilError(t, err) + assert.NilError(t, tw.Close()) + return buf +} + func TestImageImportErrors(t *testing.T) { nerdtest.Setup() @@ -143,6 +158,55 @@ func TestImageImport(t *testing.T) { } }, }, + { + Description: "image import with change", + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + cmd := helpers.Command("import", + "--change", `CMD ["echo","hi"]`, + "--change", "ENV FOO=bar", + "--change", "WORKDIR /srv", + "--change", "EXPOSE 8080", + "-", data.Identifier()) + cmd.Feed(bytes.NewReader(minimalRootfsTar(t).Bytes())) + return cmd + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + identifier := data.Identifier() + ":latest" + return &test.Expected{ + Output: expect.All( + func(stdout string, t tig.T) { + img := nerdtest.InspectImage(helpers, identifier) + assert.Assert(t, img.Config != nil) + assert.DeepEqual(t, img.Config.Cmd, []string{"echo", "hi"}) + assert.Assert(t, slices.Contains(img.Config.Env, "FOO=bar")) + assert.Equal(t, img.Config.WorkingDir, "/srv") + _, ok := img.Config.ExposedPorts["8080/tcp"] + assert.Assert(t, ok) + }, + ), + } + }, + }, + { + Description: "image import --change rejected for a standard image archive", + // nerdctl-only: Docker's import treats any tarball as a rootfs and has + // no standard-image-archive rejection. + Require: require.Not(nerdtest.Docker), + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + cmd := helpers.Command("import", "--change", `CMD ["echo"]`, "-", data.Identifier()) + cmd.Feed(bytes.NewReader(minimalImageArchiveTar(t).Bytes())) + return cmd + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeGenericFail, + Errors: []error{errors.New("filesystem archive")}, + } + }, + }, { Description: "image import with platform", Cleanup: func(data test.Data, helpers test.Helpers) { diff --git a/docs/command-reference.md b/docs/command-reference.md index bf6ceb2a0ed..9f5dae60b67 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -970,10 +970,9 @@ Usage: `nerdctl import [OPTIONS] file|URL|- [REPOSITORY[:TAG]]` Flags: - :whale: `-m, --message`: Set commit message for imported image +- :whale: `-c, --change`: Apply a Dockerfile instruction to the created image, e.g. `--change 'CMD ["echo"]'`. Repeatable. Supported instructions: `CMD`, `ENTRYPOINT`, `ENV`, `EXPOSE`, `LABEL`, `USER`, `VOLUME`, `WORKDIR`, `STOPSIGNAL`. - :nerd_face: `--platform=(linux/amd64|linux/arm64|...)`: Set platform for the imported image -Unimplemented `docker import` flags: `--change` - ### :whale: nerdctl tag Create a tag TARGET\_IMAGE that refers to SOURCE\_IMAGE. diff --git a/go.mod b/go.mod index 895af9474d1..8cf276e6460 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/docker/cli v29.8.1+incompatible //gomodjail:unconfined github.com/docker/go-connections v0.8.1 //gomodjail:unconfined github.com/docker/go-units v0.5.0 + github.com/moby/buildkit v0.33.0 github.com/moby/moby/client v0.6.0 //gomodjail:unconfined github.com/moby/moby/v2 v2.0.0-beta.24 //gomodjail:unconfined github.com/moby/sys/mount v0.3.5 //gomodjail:unconfined @@ -90,20 +91,21 @@ require ( //gomodjail:unconfined github.com/containerd/ttrpc v1.2.9 // indirect //gomodjail:unconfined - github.com/docker/docker-credential-helpers v0.9.3 // indirect + github.com/docker/docker-credential-helpers v0.9.8 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/locker v1.0.1 // indirect github.com/moby/moby/api v1.56.0 // indirect //gomodjail:unconfined github.com/moby/sys/mountinfo v0.7.2 // indirect github.com/moby/sys/symlink v0.3.0 // indirect - golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc // indirect + golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect ) require ( cyphar.com/go-pathrs v0.2.5 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect + github.com/agext/levenshtein v1.2.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect //gomodjail:unconfined github.com/cilium/ebpf v0.22.0 // indirect @@ -146,6 +148,7 @@ require ( github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect //gomodjail:unconfined github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect github.com/sasha-s/go-deadlock v0.3.5 // indirect @@ -157,6 +160,7 @@ require ( github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6 // indirect //gomodjail:unconfined github.com/tinylib/msgp v1.3.0 // indirect + github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 // indirect //gomodjail:unconfined github.com/vbatts/tar-split v0.12.3 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect diff --git a/go.sum b/go.sum index 3a2c6b11faf..1e075707d38 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/Microsoft/hcsshim v0.15.0-rc.4 h1:aZFX4LH0S20Lgjq0wG61StIClj7im4yzrxI github.com/Microsoft/hcsshim v0.15.0-rc.4/go.mod h1:BA9CBztgu4h/6Jsvo1O1M4qjWw09PoYpaEYgexPE578= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= +github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cilium/ebpf v0.22.0 h1:v2ktp0roffpMOj2MMf3idtCQZOsAoC4BJbAJN+ke2bY= @@ -89,8 +91,8 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/cli v29.8.1+incompatible h1:qYL1bCp6cRw2SB1xmLlIOPyV171dilw9W2Jew38vy9c= github.com/docker/cli v29.8.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= -github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= +github.com/docker/docker-credential-helpers v0.9.8 h1:bIREROb7So6PRlq6KTtdS9MPEjC29OQRkFNlvK2OX8Q= +github.com/docker/docker-credential-helpers v0.9.8/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M= github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -127,8 +129,8 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY= -github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -165,6 +167,8 @@ github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dz github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/moby/buildkit v0.33.0 h1:zBbt1FiMcTB/oFg1iCNcKa83k5Rn8MGcVjXFIcfYhuQ= +github.com/moby/buildkit v0.33.0/go.mod h1:uNKSZnfMk1aSa18JiCR5BT68M/3jIDGo6XuAByUK3ek= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= @@ -225,6 +229,8 @@ github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -257,6 +263,8 @@ github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWD github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= +github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 h1:2f304B10LaZdB8kkVEaoXvAMVan2tl9AiK4G0odjQtE= +github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0/go.mod h1:278M4p8WsNh3n4a1eqiFcV2FGk7wE5fwUpUom9mK9lE= github.com/vbatts/tar-split v0.12.3 h1:Cd46rkGXI3Td4yrVNwU8ripbxFaQbmesqhjBUUYAJSw= github.com/vbatts/tar-split v0.12.3/go.mod h1:sQOc6OlqGCr7HkGx/IDBeKiTIvqhmj8KffNhEXG4Nq0= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= @@ -301,8 +309,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= -golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc= -golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= +golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q= +golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= diff --git a/pkg/api/types/import_types.go b/pkg/api/types/import_types.go index e78d03ae92d..fbd107a4f1c 100644 --- a/pkg/api/types/import_types.go +++ b/pkg/api/types/import_types.go @@ -28,4 +28,7 @@ type ImageImportOptions struct { Reference string Message string Platform string + // Changes holds Dockerfile-style instructions (--change) applied to the + // imported image's config, e.g. `CMD ["echo"]` or `ENV FOO=bar`. + Changes []string } diff --git a/pkg/cmd/image/import.go b/pkg/cmd/image/import.go index 432d5665a90..7d30a3fae8b 100644 --- a/pkg/cmd/image/import.go +++ b/pkg/cmd/image/import.go @@ -49,6 +49,12 @@ import ( ) func Import(ctx context.Context, client *containerd.Client, options types.ImageImportOptions) (string, error) { + // Validate --change before any layer work, so a syntactic error fails fast + // instead of after the (possibly large) layer is compressed and committed. + if err := applyChanges(&ocispec.ImageConfig{}, options.Changes); err != nil { + return "", err + } + prefix := options.Reference if prefix == "" { prefix = fmt.Sprintf("import-%s", time.Now().Format("2006-01-02")) @@ -111,6 +117,12 @@ func ensureOCIArchive(ctx context.Context, client *containerd.Client, r io.ReadC combined := io.NopCloser(io.MultiReader(buf, r)) if isStandardArchive { + // A standard image archive already carries its own config; --change only + // applies to a filesystem (rootfs) import, which builds a fresh config. + if len(options.Changes) > 0 { + r.Close() + return nil, func() {}, fmt.Errorf("--change is only supported when importing a filesystem archive, not a standard image archive") + } return combined, func() { r.Close() }, nil } @@ -268,6 +280,11 @@ func buildImageConfig(diffID digest.Digest, options types.ImageImportOptions) ([ }}, } + // Apply any --change instructions to the fresh config. + if err := applyChanges(&imgConfig.Config, options.Changes); err != nil { + return nil, "", err + } + configJSON, err := json.Marshal(imgConfig) if err != nil { return nil, "", err diff --git a/pkg/cmd/image/import_change.go b/pkg/cmd/image/import_change.go new file mode 100644 index 00000000000..e2bbfba2267 --- /dev/null +++ b/pkg/cmd/image/import_change.go @@ -0,0 +1,214 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package image + +import ( + "fmt" + "strconv" + "strings" + + "github.com/moby/buildkit/frontend/dockerfile/instructions" + "github.com/moby/buildkit/frontend/dockerfile/parser" + "github.com/moby/buildkit/frontend/dockerfile/shell" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +// applyChanges applies each --change to cfg in order, using BuildKit's +// Dockerfile parser so the syntax matches what build accepts. +func applyChanges(cfg *ocispec.ImageConfig, changes []string) error { + for _, c := range changes { + if err := applyChange(cfg, c); err != nil { + return fmt.Errorf("invalid --change %q: %w", c, err) + } + } + return nil +} + +// applyChange parses a single change and writes it into cfg. +func applyChange(cfg *ocispec.ImageConfig, change string) error { + cmd, err := parseInstruction(change) + if err != nil || cmd == nil { + return err + } + if err := expand(cfg, cmd); err != nil { + return err + } + return dispatch(cfg, cmd) +} + +// parseInstruction parses one change into a typed instruction. More than one +// is rejected, so a change cannot smuggle in a second. +func parseInstruction(change string) (any, error) { + res, err := parser.Parse(strings.NewReader(change)) + if err != nil { + return nil, err + } + switch len(res.AST.Children) { + case 0: + return nil, nil + case 1: + default: + return nil, fmt.Errorf("must be a single instruction, got %d", len(res.AST.Children)) + } + return instructions.ParseInstruction(res.AST.Children[0]) +} + +// expand unquotes the instruction's words and resolves $VAR against the config +// built so far; the parser hands back raw tokens. An unset variable expands to +// empty, as it does in docker import. +func expand(cfg *ocispec.ImageConfig, cmd any) error { + expandable, ok := cmd.(interface { + Expand(instructions.SingleWordExpander) error + }) + if !ok { + return nil + } + lex := shell.NewLex('\\') + env := shell.EnvsFromSlice(cfg.Env) + return expandable.Expand(func(word string) (string, error) { + w, _, err := lex.ProcessWord(word, env) + return w, err + }) +} + +// dispatch writes one parsed instruction into cfg. The type switch is the +// allow-list: anything that builds rather than describes has no case. +func dispatch(cfg *ocispec.ImageConfig, cmd any) error { + switch c := cmd.(type) { + case *instructions.CmdCommand: + cfg.Cmd = cmdLine(c.ShellDependantCmdLine) + case *instructions.EntrypointCommand: + cfg.Entrypoint = cmdLine(c.ShellDependantCmdLine) + case *instructions.EnvCommand: + for _, kv := range c.Env { + cfg.Env = setEnv(cfg.Env, kv.Key, kv.Value) + } + case *instructions.LabelCommand: + if cfg.Labels == nil && len(c.Labels) > 0 { + cfg.Labels = map[string]string{} + } + for _, kv := range c.Labels { + cfg.Labels[kv.Key] = kv.Value + } + case *instructions.ExposeCommand: + // Ports stay raw tokens, so ranges and the default proto are ours. + return parseExpose(cfg, c.Ports) + case *instructions.VolumeCommand: + if cfg.Volumes == nil && len(c.Volumes) > 0 { + cfg.Volumes = map[string]struct{}{} + } + for _, v := range c.Volumes { + cfg.Volumes[v] = struct{}{} + } + case *instructions.UserCommand: + cfg.User = c.User + case *instructions.WorkdirCommand: + cfg.WorkingDir = c.Path + case *instructions.StopSignalCommand: + cfg.StopSignal = c.Signal + default: + return fmt.Errorf("the %s instruction is not supported by import", instructionName(cmd)) + } + return nil +} + +// cmdLine returns the argv for CMD/ENTRYPOINT, wrapping the shell form in +// "/bin/sh -c" like Docker. +func cmdLine(c instructions.ShellDependantCmdLine) []string { + if len(c.CmdLine) == 0 { + return nil + } + if c.PrependShell { + return append([]string{"/bin/sh", "-c"}, c.CmdLine...) + } + return c.CmdLine +} + +// instructionName returns the Dockerfile keyword for a parsed instruction, for +// use in error messages. FROM parses to a stage rather than a command, so it +// has no Name of its own. +func instructionName(cmd any) string { + switch c := cmd.(type) { + case instructions.Command: + return strings.ToUpper(c.Name()) + case *instructions.Stage: + return "FROM" + default: + return fmt.Sprintf("%T", cmd) + } +} + +// parseExpose adds each "port[/proto]" token to cfg.ExposedPorts, defaulting the +// protocol to tcp. A "start-end" port range is expanded to one entry per port, +// matching Docker's EXPOSE. +func parseExpose(cfg *ocispec.ImageConfig, ports []string) error { + for _, tok := range ports { + portSpec, proto := tok, "tcp" + if p, pr, ok := strings.Cut(tok, "/"); ok { + portSpec, proto = p, strings.ToLower(pr) + } + if proto != "tcp" && proto != "udp" && proto != "sctp" { + return fmt.Errorf("invalid EXPOSE protocol %q", proto) + } + lo, hi, err := parsePortRange(portSpec) + if err != nil { + return err + } + if cfg.ExposedPorts == nil { + cfg.ExposedPorts = map[string]struct{}{} + } + // uint32 counter so hi == 65535 does not wrap a uint16 into an endless loop. + for p := lo; p <= hi; p++ { + cfg.ExposedPorts[fmt.Sprintf("%d/%s", p, proto)] = struct{}{} + } + } + return nil +} + +// parsePortRange parses a single port or an inclusive "start-end" range into its +// low and high bounds. +func parsePortRange(s string) (uint32, uint32, error) { + if loStr, hiStr, ok := strings.Cut(s, "-"); ok { + lo, err1 := strconv.ParseUint(loStr, 10, 16) + hi, err2 := strconv.ParseUint(hiStr, 10, 16) + if err1 != nil || err2 != nil { + return 0, 0, fmt.Errorf("invalid EXPOSE port range %q", s) + } + if lo > hi { + return 0, 0, fmt.Errorf("invalid EXPOSE port range %q", s) + } + return uint32(lo), uint32(hi), nil + } + p, err := strconv.ParseUint(s, 10, 16) + if err != nil { + return 0, 0, fmt.Errorf("invalid EXPOSE port %q", s) + } + return uint32(p), uint32(p), nil +} + +// setEnv replaces the "key=" entry in env if present, otherwise appends it. +func setEnv(env []string, key, val string) []string { + entry := key + "=" + val + prefix := key + "=" + for i, e := range env { + if strings.HasPrefix(e, prefix) { + env[i] = entry + return env + } + } + return append(env, entry) +} diff --git a/pkg/cmd/image/import_change_test.go b/pkg/cmd/image/import_change_test.go new file mode 100644 index 00000000000..b474d5d2487 --- /dev/null +++ b/pkg/cmd/image/import_change_test.go @@ -0,0 +1,151 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package image + +import ( + "testing" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "gotest.tools/v3/assert" +) + +func TestApplyChanges(t *testing.T) { + testCases := []struct { + name string + changes []string + want ocispec.ImageConfig + }{ + { + name: "CMD exec form", + changes: []string{`CMD ["echo","hi"]`}, + want: ocispec.ImageConfig{Cmd: []string{"echo", "hi"}}, + }, + { + name: "CMD shell form wraps in sh -c", + changes: []string{"CMD echo hi there"}, + want: ocispec.ImageConfig{Cmd: []string{"/bin/sh", "-c", "echo hi there"}}, + }, + { + name: "CMD starting with bracket falls back to shell form", + changes: []string{"CMD [ -f /healthy ]"}, + want: ocispec.ImageConfig{Cmd: []string{"/bin/sh", "-c", "[ -f /healthy ]"}}, + }, + { + name: "ENTRYPOINT exec form", + changes: []string{`ENTRYPOINT ["/app","--flag"]`}, + want: ocispec.ImageConfig{Entrypoint: []string{"/app", "--flag"}}, + }, + { + name: "ENV key=value pairs with quoted value", + changes: []string{`ENV FOO=bar BAZ="q u x"`}, + want: ocispec.ImageConfig{Env: []string{"FOO=bar", "BAZ=q u x"}}, + }, + { + name: "ENV legacy key value form", + changes: []string{"ENV FOO bar baz"}, + want: ocispec.ImageConfig{Env: []string{"FOO=bar baz"}}, + }, + { + name: "ENV later change overrides same key", + changes: []string{"ENV FOO=a", "ENV FOO=b"}, + want: ocispec.ImageConfig{Env: []string{"FOO=b"}}, + }, + { + name: "ENV value expands an earlier ENV", + changes: []string{"ENV FOO=bar", "ENV B=$FOO"}, + want: ocispec.ImageConfig{Env: []string{"FOO=bar", "B=bar"}}, + }, + { + name: "ENV unset variable expands to empty", + changes: []string{"ENV B=x$NOPE.y"}, + want: ocispec.ImageConfig{Env: []string{"B=x.y"}}, + }, + { + name: "LABEL pairs", + changes: []string{`LABEL a=1 b="two words"`}, + want: ocispec.ImageConfig{Labels: map[string]string{"a": "1", "b": "two words"}}, + }, + { + name: "EXPOSE default tcp and explicit udp", + changes: []string{"EXPOSE 80 53/udp"}, + want: ocispec.ImageConfig{ExposedPorts: map[string]struct{}{"80/tcp": {}, "53/udp": {}}}, + }, + { + name: "EXPOSE port range expands to each port", + changes: []string{"EXPOSE 8080-8082"}, + want: ocispec.ImageConfig{ExposedPorts: map[string]struct{}{"8080/tcp": {}, "8081/tcp": {}, "8082/tcp": {}}}, + }, + { + name: "EXPOSE protocol is case-insensitive", + changes: []string{"EXPOSE 80/TCP"}, + want: ocispec.ImageConfig{ExposedPorts: map[string]struct{}{"80/tcp": {}}}, + }, + { + name: "VOLUME json and shell forms", + changes: []string{`VOLUME ["/data"]`, "VOLUME /a /b"}, + want: ocispec.ImageConfig{Volumes: map[string]struct{}{"/data": {}, "/a": {}, "/b": {}}}, + }, + { + name: "USER WORKDIR STOPSIGNAL", + changes: []string{"USER nobody:nogroup", "WORKDIR /srv", "STOPSIGNAL SIGTERM"}, + want: ocispec.ImageConfig{User: "nobody:nogroup", WorkingDir: "/srv", StopSignal: "SIGTERM"}, + }, + { + name: "instruction keyword is case-insensitive", + changes: []string{"workdir /w"}, + want: ocispec.ImageConfig{WorkingDir: "/w"}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var cfg ocispec.ImageConfig + err := applyChanges(&cfg, tc.changes) + assert.NilError(t, err) + assert.DeepEqual(t, tc.want, cfg) + }) + } +} + +func TestApplyChangesErrors(t *testing.T) { + testCases := []struct { + name string + change string + errSub string + }{ + {"unknown instruction", "NOSUCHTHING foo", "unknown instruction"}, + {"run unsupported", "RUN echo hi", "not supported by import"}, + {"copy unsupported", "COPY a b", "not supported by import"}, + {"from unsupported", "FROM alpine", "not supported by import"}, + {"healthcheck unsupported", "HEALTHCHECK CMD true", "not supported by import"}, + {"onbuild unsupported", "ONBUILD RUN true", "not supported by import"}, + {"shell unsupported", `SHELL ["/bin/bash","-c"]`, "not supported by import"}, + {"expose non-numeric port", "EXPOSE http", "invalid EXPOSE port"}, + {"expose bad proto", "EXPOSE 80/icmp", "invalid EXPOSE protocol"}, + {"expose reversed range", "EXPOSE 90-80", "invalid EXPOSE port range"}, + {"label without value", "LABEL a=1 b", "can't find = in"}, + {"env legacy without a value", "ENV LONELYKEY", "two arguments"}, + {"env unterminated quote", `ENV A="oops`, "matching double-quote"}, + {"a change may hold only one instruction", "ENV A=b\nRUN touch /x", "single instruction"}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var cfg ocispec.ImageConfig + err := applyChanges(&cfg, []string{tc.change}) + assert.ErrorContains(t, err, tc.errSub) + }) + } +}