diff --git a/cmd/podman/containers/commit.go b/cmd/podman/containers/commit.go index fa0621a4cc..c771d4c788 100644 --- a/cmd/podman/containers/commit.go +++ b/cmd/podman/containers/commit.go @@ -9,6 +9,7 @@ import ( "github.com/containers/common/pkg/completion" "github.com/containers/podman/v4/cmd/podman/common" "github.com/containers/podman/v4/cmd/podman/registry" + "github.com/containers/podman/v4/pkg/api/handlers" "github.com/containers/podman/v4/pkg/domain/entities" "github.com/spf13/cobra" ) @@ -47,7 +48,7 @@ var ( commitOptions = entities.CommitOptions{ ImageName: "", } - iidFile string + configFile, iidFile string ) func commitFlags(cmd *cobra.Command) { @@ -57,6 +58,10 @@ func commitFlags(cmd *cobra.Command) { flags.StringArrayVarP(&commitOptions.Changes, changeFlagName, "c", []string{}, "Apply the following possible instructions to the created image (default []): "+strings.Join(common.ChangeCmds, " | ")) _ = cmd.RegisterFlagCompletionFunc(changeFlagName, common.AutocompleteChangeInstructions) + configFileFlagName := "config" + flags.StringVar(&configFile, configFileFlagName, "", "`file` containing a container configuration to merge into the image") + _ = cmd.RegisterFlagCompletionFunc(configFileFlagName, completion.AutocompleteDefault) + formatFlagName := "format" flags.StringVarP(&commitOptions.Format, formatFlagName, "f", "oci", "`Format` of the image manifest and metadata") _ = cmd.RegisterFlagCompletionFunc(formatFlagName, common.AutocompleteImageFormat) @@ -100,7 +105,16 @@ func commit(cmd *cobra.Command, args []string) error { if !commitOptions.Quiet { commitOptions.Writer = os.Stderr } - + if len(commitOptions.Changes) > 0 { + commitOptions.Changes = handlers.DecodeChanges(commitOptions.Changes) + } + if len(configFile) > 0 { + cfg, err := os.ReadFile(configFile) + if err != nil { + return fmt.Errorf("--config: %w", err) + } + commitOptions.Config = cfg + } response, err := registry.ContainerEngine().ContainerCommit(context.Background(), container, commitOptions) if err != nil { return err diff --git a/cmd/podman/containers/create.go b/cmd/podman/containers/create.go index 2da313c68a..6d97ea7d65 100644 --- a/cmd/podman/containers/create.go +++ b/cmd/podman/containers/create.go @@ -400,7 +400,7 @@ func createPodIfNecessary(cmd *cobra.Command, s *specgen.SpecGenerator, netOpts var err error uns := specgen.Namespace{NSMode: specgen.Default} if cliVals.UserNS != "" { - uns, err = specgen.ParseNamespace(cliVals.UserNS) + uns, err = specgen.ParseUserNamespace(cliVals.UserNS) if err != nil { return err } diff --git a/docs/source/markdown/options/volume.image.md b/docs/source/markdown/options/volume.image.md index 7549b47ea3..b4715f8545 100644 --- a/docs/source/markdown/options/volume.image.md +++ b/docs/source/markdown/options/volume.image.md @@ -4,9 +4,8 @@ ####> are applicable to all of those. #### **--volume**, **-v**=*[HOST-DIR:CONTAINER-DIR[:OPTIONS]]* -Create a bind mount. Specifying the `-v /HOST-DIR:/CONTAINER-DIR` option, Podman -bind mounts `/HOST-DIR` from the host to `/CONTAINER-DIR` in the Podman -container. +Mount a host directory into containers when executing RUN instructions during +the build. The `OPTIONS` are a comma-separated list and can be: [[1]](#Footnote1) @@ -17,12 +16,9 @@ The `OPTIONS` are a comma-separated list and can be: [[1]](#Footnote1) 0 { tag = query.Tag } @@ -494,7 +501,7 @@ func CommitContainer(w http.ResponseWriter, r *http.Request) { options.Author = query.Author options.Pause = query.Pause options.Squash = query.Squash - options.Changes = query.Changes + options.Changes = handlers.DecodeChanges(query.Changes) ctr, err := runtime.LookupContainer(query.Container) if err != nil { utils.Error(w, http.StatusNotFound, err) diff --git a/pkg/bindings/containers/commit.go b/pkg/bindings/containers/commit.go index 5138b13cb6..6d094a2ff8 100644 --- a/pkg/bindings/containers/commit.go +++ b/pkg/bindings/containers/commit.go @@ -33,7 +33,11 @@ func Commit(ctx context.Context, nameOrID string, options *CommitOptions) (entit return entities.IDResponse{}, err } params.Set("container", nameOrID) - response, err := conn.DoRequest(ctx, nil, http.MethodPost, "/commit", params, nil) + var requestBody io.Reader + if options.Config != nil { + requestBody = *options.Config + } + response, err := conn.DoRequest(ctx, requestBody, http.MethodPost, "/commit", params, nil) if err != nil { return id, err } diff --git a/pkg/bindings/containers/types.go b/pkg/bindings/containers/types.go index 6678a86ff3..ee2fe4b94b 100644 --- a/pkg/bindings/containers/types.go +++ b/pkg/bindings/containers/types.go @@ -29,6 +29,7 @@ type LogOptions struct { type CommitOptions struct { Author *string Changes []string + Config *io.Reader `schema:"-"` Comment *string Format *string Pause *bool diff --git a/pkg/bindings/containers/types_commit_options.go b/pkg/bindings/containers/types_commit_options.go index d58630b924..20e59f4d50 100644 --- a/pkg/bindings/containers/types_commit_options.go +++ b/pkg/bindings/containers/types_commit_options.go @@ -2,6 +2,7 @@ package containers import ( + "io" "net/url" "github.com/containers/podman/v4/pkg/bindings/internal/util" @@ -47,6 +48,21 @@ func (o *CommitOptions) GetChanges() []string { return o.Changes } +// WithConfig set field Config to given value +func (o *CommitOptions) WithConfig(value io.Reader) *CommitOptions { + o.Config = &value + return o +} + +// GetConfig returns value of field Config +func (o *CommitOptions) GetConfig() io.Reader { + if o.Config == nil { + var z io.Reader + return z + } + return *o.Config +} + // WithComment set field Comment to given value func (o *CommitOptions) WithComment(value string) *CommitOptions { o.Comment = &value diff --git a/pkg/domain/entities/containers.go b/pkg/domain/entities/containers.go index a47b9ed20c..44cf3fc517 100644 --- a/pkg/domain/entities/containers.go +++ b/pkg/domain/entities/containers.go @@ -164,6 +164,7 @@ type ContainerStatReport struct { type CommitOptions struct { Author string Changes []string + Config []byte Format string ImageName string IncludeVolumes bool diff --git a/pkg/domain/infra/abi/config.go b/pkg/domain/infra/abi/config.go new file mode 100644 index 0000000000..ae564cf442 --- /dev/null +++ b/pkg/domain/infra/abi/config.go @@ -0,0 +1,22 @@ +package abi + +import ( + "encoding/json" + "errors" + "io" + + "github.com/containers/image/v5/manifest" +) + +// DecodeOverrideConfig reads a Schema2Config from a Reader, suppressing EOF +// errors. +func DecodeOverrideConfig(reader io.Reader) (*manifest.Schema2Config, error) { + config := manifest.Schema2Config{} + if reader != nil { + err := json.NewDecoder(reader).Decode(&config) + if err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + } + return &config, nil +} diff --git a/pkg/domain/infra/abi/config_test.go b/pkg/domain/infra/abi/config_test.go new file mode 100644 index 0000000000..4a9af42e43 --- /dev/null +++ b/pkg/domain/infra/abi/config_test.go @@ -0,0 +1,56 @@ +package abi + +import ( + "strings" + "testing" + + "github.com/containers/image/v5/manifest" + "github.com/stretchr/testify/assert" +) + +func TestDecodeOverrideConfig(t *testing.T) { + testCases := []struct { + description string + body string + expectedValue *manifest.Schema2Config + expectedError bool + }{ + { + description: "nothing", + body: ``, + expectedValue: &manifest.Schema2Config{}, + }, + { + description: "empty", + body: `{}`, + expectedValue: &manifest.Schema2Config{}, + }, + { + description: "user", + body: `{"User":"0:0"}`, + expectedValue: &manifest.Schema2Config{User: "0:0"}, + }, + { + description: "malformed", + body: `{"User":`, + expectedError: true, + }, + } + t.Run("no reader", func(t *testing.T) { + value, err := DecodeOverrideConfig(nil) + assert.NoErrorf(t, err, "decoding nothing") + assert.NotNilf(t, value, "decoded value was unexpectedly nil") + }) + for _, testCase := range testCases { + t.Run(testCase.description, func(t *testing.T) { + value, err := DecodeOverrideConfig(strings.NewReader(testCase.body)) + if testCase.expectedError { + assert.Errorf(t, err, "decoding sample data") + } else { + assert.NoErrorf(t, err, "decoding sample data") + assert.NotNilf(t, value, "decoded value was unexpectedly nil") + assert.Equalf(t, *testCase.expectedValue, *value, "decoded value was not what we expected") + } + }) + } +} diff --git a/pkg/domain/infra/abi/containers.go b/pkg/domain/infra/abi/containers.go index b495bc91b7..6f6d86668f 100644 --- a/pkg/domain/infra/abi/containers.go +++ b/pkg/domain/infra/abi/containers.go @@ -1,6 +1,7 @@ package abi import ( + "bytes" "context" "errors" "fmt" @@ -17,6 +18,7 @@ import ( "github.com/containers/podman/v4/libpod" "github.com/containers/podman/v4/libpod/define" "github.com/containers/podman/v4/libpod/logs" + "github.com/containers/podman/v4/pkg/api/handlers" "github.com/containers/podman/v4/pkg/checkpoint" "github.com/containers/podman/v4/pkg/domain/entities" "github.com/containers/podman/v4/pkg/domain/entities/reports" @@ -581,18 +583,29 @@ func (ic *ContainerEngine) ContainerCommit(ctx context.Context, nameOrID string, } sc := ic.Libpod.SystemContext() + var changes []string + if len(options.Changes) > 0 { + changes = handlers.DecodeChanges(options.Changes) + } + var overrideConfig *manifest.Schema2Config + if len(options.Config) > 0 { + if overrideConfig, err = DecodeOverrideConfig(bytes.NewReader(options.Config)); err != nil { + return nil, err + } + } coptions := buildah.CommitOptions{ SignaturePolicyPath: rtc.Engine.SignaturePolicyPath, ReportWriter: options.Writer, SystemContext: sc, PreferredManifestType: mimeType, + OverrideConfig: overrideConfig, } opts := libpod.ContainerCommitOptions{ CommitOptions: coptions, Pause: options.Pause, IncludeVolumes: options.IncludeVolumes, Message: options.Message, - Changes: options.Changes, + Changes: changes, Author: options.Author, Squash: options.Squash, } diff --git a/pkg/domain/infra/tunnel/containers.go b/pkg/domain/infra/tunnel/containers.go index d57d2cd669..aec85bc5a5 100644 --- a/pkg/domain/infra/tunnel/containers.go +++ b/pkg/domain/infra/tunnel/containers.go @@ -1,6 +1,7 @@ package tunnel import ( + "bytes" "context" "errors" "fmt" @@ -347,7 +348,15 @@ func (ic *ContainerEngine) ContainerCommit(ctx context.Context, nameOrID string, return nil, fmt.Errorf("invalid image name %q", opts.ImageName) } } - options := new(containers.CommitOptions).WithAuthor(opts.Author).WithChanges(opts.Changes).WithComment(opts.Message).WithSquash(opts.Squash).WithStream(!opts.Quiet) + var changes []string + if len(opts.Changes) > 0 { + changes = handlers.DecodeChanges(opts.Changes) + } + var configReader io.Reader + if len(opts.Config) > 0 { + configReader = bytes.NewReader(opts.Config) + } + options := new(containers.CommitOptions).WithAuthor(opts.Author).WithChanges(changes).WithComment(opts.Message).WithConfig(configReader).WithSquash(opts.Squash).WithStream(!opts.Quiet) options.WithFormat(opts.Format).WithPause(opts.Pause).WithRepo(repo).WithTag(tag) response, err := containers.Commit(ic.ClientCtx, nameOrID, options) if err != nil { diff --git a/test/apiv2/14-commit.at b/test/apiv2/14-commit.at new file mode 100644 index 0000000000..1c4b11314f --- /dev/null +++ b/test/apiv2/14-commit.at @@ -0,0 +1,30 @@ +# Create a container for testing the container initializing later +podman create -t -i --name myctr $IMAGE ls + +config=$(mktemp -t config.XXXXXXXXXX.json) +cat > "$config" <<- EOF +{ + "Entrypoint": ["/bin/crash"], + "Cmd": ["and", "burn"], + "Labels": {"for": "ever", "and": "ever"} +} +EOF + +# Create a new image based on the container +t POST 'libpod/commit?container=myctr&repo=nativeimage&tag=1' $config 200 + +# Check some things +t GET libpod/images/nativeimage:1/json 200 ".Config.Cmd=$(jq .Cmd $config)" ".Config.Entrypoint=$(jq .Entrypoint $config)" + +# Create a new image based on the container +t POST 'commit?container=myctr&repo=compatimage&tag=1' $config 201 + +# Check some things +t GET images/compatimage:1/json 200 ".Config.Cmd=$(jq .Cmd $config)" ".Config.Entrypoint=$(jq .Entrypoint $config)" + +# Clean up +t DELETE containers/myctr 204 +t DELETE images/nativeimage:1 200 +t DELETE images/compatimage:1 200 +rm -f "$config" +unset config diff --git a/test/e2e/commit_test.go b/test/e2e/commit_test.go index 6a9a7e614f..b1bbb29b61 100644 --- a/test/e2e/commit_test.go +++ b/test/e2e/commit_test.go @@ -21,7 +21,7 @@ var _ = Describe("Podman commit", func() { session := podmanTest.Podman([]string{"commit", "test1", "--change", "BOGUS=foo", "foobar.com/test1-image:latest"}) session.WaitWithDefaultTimeout() Expect(session).Should(Exit(125)) - Expect(session.ErrorToString()).To(Equal("Error: invalid change \"BOGUS=foo\" - invalid instruction BOGUS")) + Expect(session.ErrorToString()).To(HaveSuffix(`applying changes: processing change "BOGUS foo": did not understand change instruction "BOGUS foo"`)) session = podmanTest.Podman([]string{"commit", "test1", "foobar.com/test1-image:latest"}) session.WaitWithDefaultTimeout() @@ -127,6 +127,45 @@ var _ = Describe("Podman commit", func() { Expect(inspectResults[0].Labels).To(HaveKeyWithValue("image", "blue")) }) + It("podman commit container with --config flag", func() { + test := podmanTest.Podman([]string{"run", "--name", "test1", "-d", ALPINE, "ls"}) + test.WaitWithDefaultTimeout() + Expect(test).Should(ExitCleanly()) + Expect(podmanTest.NumberOfContainers()).To(Equal(1)) + + configFile, err := os.CreateTemp(podmanTest.TempDir, "") + Expect(err).Should(Succeed()) + _, err = configFile.WriteString(`{"Labels":{"image":"green"}}`) + Expect(err).Should(Succeed()) + configFile.Close() + + session := podmanTest.Podman([]string{"commit", "-q", "--config", configFile.Name(), "test1", "foobar.com/test1-image:latest"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + check := podmanTest.Podman([]string{"inspect", "foobar.com/test1-image:latest"}) + check.WaitWithDefaultTimeout() + inspectResults := check.InspectImageJSON() + Expect(inspectResults[0].Labels).To(HaveKeyWithValue("image", "green")) + }) + + It("podman commit container with --config pointing to trash", func() { + test := podmanTest.Podman([]string{"run", "--name", "test1", "-d", ALPINE, "ls"}) + test.WaitWithDefaultTimeout() + Expect(test).Should(ExitCleanly()) + Expect(podmanTest.NumberOfContainers()).To(Equal(1)) + + configFile, err := os.CreateTemp(podmanTest.TempDir, "") + Expect(err).Should(Succeed()) + _, err = configFile.WriteString("this is not valid JSON\n") + Expect(err).Should(Succeed()) + configFile.Close() + + session := podmanTest.Podman([]string{"commit", "-q", "--config", configFile.Name(), "test1", "foobar.com/test1-image:latest"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(Not(ExitCleanly())) + }) + It("podman commit container with --squash", func() { test := podmanTest.Podman([]string{"run", "--name", "test1", "-d", ALPINE, "ls"}) test.WaitWithDefaultTimeout()