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 9e32e1d8adf..e31bdf64fc0 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -944,10 +944,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/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..68eaf1c3ecc --- /dev/null +++ b/pkg/cmd/image/import_change.go @@ -0,0 +1,296 @@ +/* + 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 ( + "encoding/json" + "fmt" + "strconv" + "strings" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +// applyChanges applies each Dockerfile-style change (as passed to --change) to +// cfg in order. It supports only the instructions representable in an OCI image +// config; Docker-only instructions and unknown ones are rejected. +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 "INSTRUCTION args" change and mutates cfg. The +// instruction keyword is case-insensitive, matching Docker. +func applyChange(cfg *ocispec.ImageConfig, change string) error { + instr, args := splitInstruction(change) + if instr == "" { + return nil + } + switch strings.ToUpper(instr) { + case "CMD": + cfg.Cmd = execOrShellForm(args) + case "ENTRYPOINT": + cfg.Entrypoint = execOrShellForm(args) + case "ENV": + pairs, err := parseEnv(args) + if err != nil { + return err + } + for _, kv := range pairs { + cfg.Env = setEnv(cfg.Env, kv[0], kv[1]) + } + case "LABEL": + pairs, err := parseLabel(args) + if err != nil { + return err + } + if cfg.Labels == nil && len(pairs) > 0 { + cfg.Labels = map[string]string{} + } + for _, kv := range pairs { + cfg.Labels[kv[0]] = kv[1] + } + case "EXPOSE": + if err := parseExpose(cfg, args); err != nil { + return err + } + case "VOLUME": + vols := stringList(args) + if cfg.Volumes == nil && len(vols) > 0 { + cfg.Volumes = map[string]struct{}{} + } + for _, v := range vols { + cfg.Volumes[v] = struct{}{} + } + case "USER": + cfg.User = strings.TrimSpace(args) + case "WORKDIR": + cfg.WorkingDir = strings.TrimSpace(args) + case "STOPSIGNAL": + cfg.StopSignal = strings.TrimSpace(args) + case "HEALTHCHECK", "ONBUILD", "SHELL": + // These live only in Docker's image config schema, not the OCI one that + // import writes, so they cannot be represented here. + return fmt.Errorf("the %s instruction is not supported by import", strings.ToUpper(instr)) + default: + return fmt.Errorf("unknown instruction %q", instr) + } + return nil +} + +// splitInstruction splits a change into its instruction keyword and the +// remaining argument string, trimming surrounding whitespace. +func splitInstruction(change string) (instr, args string) { + trimmed := strings.TrimSpace(change) + i := strings.IndexAny(trimmed, " \t") + if i < 0 { + return trimmed, "" + } + return trimmed[:i], strings.TrimSpace(trimmed[i+1:]) +} + +// execOrShellForm parses CMD/ENTRYPOINT arguments. A valid JSON array is the +// exec form used verbatim; anything else (including a "[" that is not valid JSON) +// is the shell form, wrapped in "/bin/sh -c" the way Docker does. +func execOrShellForm(args string) []string { + if isJSONArray(args) { + if v, err := parseJSONStringArray(args); err == nil { + return v + } + } + if args == "" { + return nil + } + return []string{"/bin/sh", "-c", args} +} + +// stringList parses VOLUME arguments: a valid JSON array, or a whitespace- +// separated list of paths (also the fallback for a non-JSON "["). +func stringList(args string) []string { + if isJSONArray(args) { + if v, err := parseJSONStringArray(args); err == nil { + return v + } + } + return strings.Fields(args) +} + +// 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, args string) error { + for _, tok := range strings.Fields(args) { + 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 +} + +// parseEnv parses ENV arguments in both forms: the legacy "ENV key value" (a +// single variable whose value is the rest of the line) and the "ENV k=v k2=v2" +// form with quote-aware values. +func parseEnv(args string) ([][2]string, error) { + first, _ := splitInstruction(args) // reuse: first whitespace-delimited token + if !strings.Contains(first, "=") { + // Legacy form: first token is the key, the remainder is the value; like + // Docker, both are required. + key, val := splitInstruction(args) + if key == "" || val == "" { + return nil, fmt.Errorf("ENV must have two arguments") + } + return [][2]string{{key, val}}, nil + } + return parseKeyValuePairs(args) +} + +// parseLabel parses LABEL arguments as quote-aware key=value pairs; a bare +// "key value" is accepted as a single label, matching Docker's legacy form. +func parseLabel(args string) ([][2]string, error) { + first, _ := splitInstruction(args) + if !strings.Contains(first, "=") { + key, val := splitInstruction(args) + if key == "" || val == "" { + return nil, fmt.Errorf("LABEL must have two arguments") + } + return [][2]string{{key, val}}, nil + } + return parseKeyValuePairs(args) +} + +// parseKeyValuePairs splits "k=v k2=v2" into pairs, honoring single and double +// quotes around values so a value may contain spaces. +func parseKeyValuePairs(args string) ([][2]string, error) { + tokens, err := tokenize(args) + if err != nil { + return nil, err + } + pairs := make([][2]string, 0, len(tokens)) + for _, tok := range tokens { + k, v, ok := strings.Cut(tok, "=") + if !ok || k == "" { + return nil, fmt.Errorf("expected key=value, got %q", tok) + } + // tokenize already strips the surrounding quotes, so v is the bare value. + pairs = append(pairs, [2]string{k, v}) + } + return pairs, nil +} + +// tokenize splits s on whitespace that is not inside single or double quotes. +func tokenize(s string) ([]string, error) { + var tokens []string + var cur strings.Builder + var quote rune + inToken := false + for _, r := range s { + switch { + case quote != 0: + if r == quote { + quote = 0 + } else { + cur.WriteRune(r) + } + case r == '\'' || r == '"': + quote = r + inToken = true + case r == ' ' || r == '\t': + if inToken { + tokens = append(tokens, cur.String()) + cur.Reset() + inToken = false + } + default: + cur.WriteRune(r) + inToken = true + } + } + if quote != 0 { + return nil, fmt.Errorf("unterminated quote") + } + if inToken { + tokens = append(tokens, cur.String()) + } + return tokens, 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) +} + +// isJSONArray reports whether args looks like a JSON array (the exec form). +func isJSONArray(args string) bool { + return strings.HasPrefix(strings.TrimSpace(args), "[") +} + +// parseJSONStringArray decodes a JSON array of strings, e.g. `["echo","hi"]`. +func parseJSONStringArray(args string) ([]string, error) { + var v []string + if err := json.Unmarshal([]byte(args), &v); err != nil { + return nil, fmt.Errorf("invalid JSON array %q: %w", args, err) + } + return v, nil +} diff --git a/pkg/cmd/image/import_change_test.go b/pkg/cmd/image/import_change_test.go new file mode 100644 index 00000000000..c923e7236b6 --- /dev/null +++ b/pkg/cmd/image/import_change_test.go @@ -0,0 +1,137 @@ +/* + 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: "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", "RUN echo hi", "unknown instruction"}, + {"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", "expected key=value"}, + {"env legacy without a value", "ENV LONELYKEY", "two arguments"}, + {"env unterminated quote", `ENV A="oops`, "unterminated quote"}, + } + 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) + }) + } +}