Skip to content

Commit ccf77bf

Browse files
committed
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, ONBUILD and SHELL only exist in Docker's config schema and are rejected with a clear error. --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 <mayur.das@neevcloud.com>
1 parent 5638af9 commit ccf77bf

7 files changed

Lines changed: 521 additions & 2 deletions

File tree

cmd/nerdctl/image/image_import.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ func ImportCommand() *cobra.Command {
4545

4646
cmd.Flags().StringP("message", "m", "", "Set commit message for imported image")
4747
cmd.Flags().String("platform", "", "Set platform for imported image (e.g., linux/amd64)")
48+
cmd.Flags().StringArrayP("change", "c", nil, "Apply Dockerfile instruction to the created image")
4849
return cmd
4950
}
5051

@@ -61,6 +62,10 @@ func importOptions(cmd *cobra.Command, args []string) (types.ImageImportOptions,
6162
if err != nil {
6263
return types.ImageImportOptions{}, err
6364
}
65+
changes, err := cmd.Flags().GetStringArray("change")
66+
if err != nil {
67+
return types.ImageImportOptions{}, err
68+
}
6469
var reference string
6570
if len(args) > 1 {
6671
reference = args[1]
@@ -97,6 +102,7 @@ func importOptions(cmd *cobra.Command, args []string) (types.ImageImportOptions,
97102
Reference: reference,
98103
Message: message,
99104
Platform: platform,
105+
Changes: changes,
100106
}, nil
101107
}
102108

cmd/nerdctl/image/image_import_linux_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"net/http"
2424
"os"
2525
"path/filepath"
26+
"slices"
2627
"strings"
2728
"testing"
2829

@@ -45,6 +46,20 @@ func minimalRootfsTar(t *testing.T) *bytes.Buffer {
4546
return buf
4647
}
4748

49+
// minimalImageArchiveTar returns a tar that looks like a standard image archive
50+
// (it carries a manifest.json), used to exercise the --change rejection path.
51+
func minimalImageArchiveTar(t *testing.T) *bytes.Buffer {
52+
t.Helper()
53+
buf := new(bytes.Buffer)
54+
tw := tar.NewWriter(buf)
55+
content := []byte("[]")
56+
assert.NilError(t, tw.WriteHeader(&tar.Header{Name: "manifest.json", Size: int64(len(content)), Mode: 0644}))
57+
_, err := tw.Write(content)
58+
assert.NilError(t, err)
59+
assert.NilError(t, tw.Close())
60+
return buf
61+
}
62+
4863
func TestImageImportErrors(t *testing.T) {
4964
nerdtest.Setup()
5065

@@ -143,6 +158,52 @@ func TestImageImport(t *testing.T) {
143158
}
144159
},
145160
},
161+
{
162+
Description: "image import with change",
163+
Cleanup: func(data test.Data, helpers test.Helpers) {
164+
helpers.Anyhow("rmi", "-f", data.Identifier())
165+
},
166+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
167+
cmd := helpers.Command("import",
168+
"--change", `CMD ["echo","hi"]`,
169+
"--change", "ENV FOO=bar",
170+
"--change", "WORKDIR /srv",
171+
"--change", "EXPOSE 8080",
172+
"-", data.Identifier())
173+
cmd.Feed(bytes.NewReader(minimalRootfsTar(t).Bytes()))
174+
return cmd
175+
},
176+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
177+
identifier := data.Identifier() + ":latest"
178+
return &test.Expected{
179+
Output: expect.All(
180+
func(stdout string, t tig.T) {
181+
img := nerdtest.InspectImage(helpers, identifier)
182+
assert.Assert(t, img.Config != nil)
183+
assert.DeepEqual(t, img.Config.Cmd, []string{"echo", "hi"})
184+
assert.Assert(t, slices.Contains(img.Config.Env, "FOO=bar"))
185+
assert.Equal(t, img.Config.WorkingDir, "/srv")
186+
_, ok := img.Config.ExposedPorts["8080/tcp"]
187+
assert.Assert(t, ok)
188+
},
189+
),
190+
}
191+
},
192+
},
193+
{
194+
Description: "image import --change rejected for a standard image archive",
195+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
196+
cmd := helpers.Command("import", "--change", `CMD ["echo"]`, "-", data.Identifier())
197+
cmd.Feed(bytes.NewReader(minimalImageArchiveTar(t).Bytes()))
198+
return cmd
199+
},
200+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
201+
return &test.Expected{
202+
ExitCode: expect.ExitCodeGenericFail,
203+
Errors: []error{errors.New("filesystem archive")},
204+
}
205+
},
206+
},
146207
{
147208
Description: "image import with platform",
148209
Cleanup: func(data test.Data, helpers test.Helpers) {

docs/command-reference.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -944,10 +944,9 @@ Usage: `nerdctl import [OPTIONS] file|URL|- [REPOSITORY[:TAG]]`
944944
Flags:
945945

946946
- :whale: `-m, --message`: Set commit message for imported image
947+
- :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`.
947948
- :nerd_face: `--platform=(linux/amd64|linux/arm64|...)`: Set platform for the imported image
948949

949-
Unimplemented `docker import` flags: `--change`
950-
951950
### :whale: nerdctl tag
952951

953952
Create a tag TARGET\_IMAGE that refers to SOURCE\_IMAGE.

pkg/api/types/import_types.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,7 @@ type ImageImportOptions struct {
2828
Reference string
2929
Message string
3030
Platform string
31+
// Changes holds Dockerfile-style instructions (--change) applied to the
32+
// imported image's config, e.g. `CMD ["echo"]` or `ENV FOO=bar`.
33+
Changes []string
3134
}

pkg/cmd/image/import.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ import (
4949
)
5050

5151
func Import(ctx context.Context, client *containerd.Client, options types.ImageImportOptions) (string, error) {
52+
// Validate --change before any layer work, so a syntactic error fails fast
53+
// instead of after the (possibly large) layer is compressed and committed.
54+
if err := applyChanges(&ocispec.ImageConfig{}, options.Changes); err != nil {
55+
return "", err
56+
}
57+
5258
prefix := options.Reference
5359
if prefix == "" {
5460
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
111117

112118
combined := io.NopCloser(io.MultiReader(buf, r))
113119
if isStandardArchive {
120+
// A standard image archive already carries its own config; --change only
121+
// applies to a filesystem (rootfs) import, which builds a fresh config.
122+
if len(options.Changes) > 0 {
123+
r.Close()
124+
return nil, func() {}, fmt.Errorf("--change is only supported when importing a filesystem archive, not a standard image archive")
125+
}
114126
return combined, func() { r.Close() }, nil
115127
}
116128

@@ -268,6 +280,11 @@ func buildImageConfig(diffID digest.Digest, options types.ImageImportOptions) ([
268280
}},
269281
}
270282

283+
// Apply any --change instructions to the fresh config.
284+
if err := applyChanges(&imgConfig.Config, options.Changes); err != nil {
285+
return nil, "", err
286+
}
287+
271288
configJSON, err := json.Marshal(imgConfig)
272289
if err != nil {
273290
return nil, "", err

0 commit comments

Comments
 (0)