diff --git a/docs/containers.conf.5.md b/docs/containers.conf.5.md index 3c111f878..d4d798e7e 100644 --- a/docs/containers.conf.5.md +++ b/docs/containers.conf.5.md @@ -807,6 +807,14 @@ Locks are recycled and can be reused after the associated container, pod, or vol The default number available is 2048. If this is changed, a lock renumbering must be performed, using the `podman system renumber` command. +**platform**="" + +Specifies the default platform for image operations such as pull, build, run, +and create. When set, container engines will use this platform instead of the +host's native platform. The format is `os/arch` or `os/arch/variant` (e.g., +`linux/amd64`, `linux/arm64/v8`). If empty (the default), the host's platform +is used. + **pod_exit_policy**="continue" Set the exit policy of the pod when the last container exits. Supported policies are: diff --git a/pkg/config/config.go b/pkg/config/config.go index 3ff319cda..b860cf966 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -423,6 +423,12 @@ type EngineConfig struct { // OCIRuntimes are the set of configured OCI runtimes (default is runc). OCIRuntimes map[string][]string `toml:"runtimes,omitempty"` + // Platform specifies the default platform (os/arch[/variant]) for image + // operations such as pull, build, run, and create. If empty, the host's + // platform is used. Format: "os/arch" or "os/arch/variant" (e.g., + // "linux/amd64", "linux/arm64/v8"). + Platform string `toml:"platform,omitempty"` + // PlatformToOCIRuntime requests specific OCI runtime for a specified platform of image. PlatformToOCIRuntime map[string]string `toml:"platform_to_oci_runtime,omitempty"` @@ -731,6 +737,40 @@ func (c *EngineConfig) ImagePlatformToRuntime(os string, arch string) string { return c.OCIRuntime } +// PlatformComponents parses the Platform field and returns the individual +// os, architecture, and variant components. Empty strings are returned for +// unset components. If Platform is empty, all returned values are empty. +func (c *EngineConfig) PlatformComponents() (os, arch, variant string) { + if c.Platform == "" { + return "", "", "" + } + parts := strings.SplitN(c.Platform, "/", 3) + switch len(parts) { + case 3: + return parts[0], parts[1], parts[2] + case 2: + return parts[0], parts[1], "" + default: + return parts[0], "", "" + } +} + +// validatePlatform checks that the Platform field, if set, is a valid +// os/arch or os/arch/variant string. +func (c *EngineConfig) validatePlatform() error { + if c.Platform == "" { + return nil + } + parts := strings.SplitN(c.Platform, "/", 3) + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { + return fmt.Errorf("invalid platform %q: must be in the format os/arch[/variant]", c.Platform) + } + if len(parts) == 3 && parts[2] == "" { + return fmt.Errorf("invalid platform %q: variant must not be empty when specified", c.Platform) + } + return nil +} + // CheckCgroupsAndAdjustConfig checks if we're running rootless with the systemd // cgroup manager. In case the user session isn't available, we're switching the // cgroup manager to cgroupfs. Note, this only applies to rootless. @@ -845,6 +885,10 @@ func (c *EngineConfig) Validate() error { return err } + if err := c.validatePlatform(); err != nil { + return err + } + if err := ValidateImageVolumeMode(c.ImageVolumeMode); err != nil { return err } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 1a3ba378c..22d509b4e 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -326,6 +326,7 @@ image_copy_tmp_dir="storage"` gomega.Expect(defaultConfig.Network.NetavarkPluginDirs.Get()).To(gomega.Equal([]string{"/usr/netavark"})) gomega.Expect(defaultConfig.Engine.NumLocks).To(gomega.BeEquivalentTo(2048)) gomega.Expect(defaultConfig.Engine.OCIRuntimes).To(gomega.Equal(OCIRuntimeMap)) + gomega.Expect(defaultConfig.Engine.Platform).To(gomega.BeEmpty()) gomega.Expect(defaultConfig.Engine.PlatformToOCIRuntime).To(gomega.Equal(PlatformToOCIRuntimeMap)) gomega.Expect(defaultConfig.Containers.HTTPProxy).To(gomega.BeFalse()) gomega.Expect(defaultConfig.Engine.NetworkCmdOptions.Get()).To(gomega.BeEmpty()) @@ -528,6 +529,7 @@ image_copy_tmp_dir="storage"` gomega.Expect(config.Containers.Privileged).To(gomega.BeTrue()) gomega.Expect(config.Containers.ReadOnly).To(gomega.BeTrue()) gomega.Expect(config.Engine.ImageParallelCopies).To(gomega.Equal(uint(10))) + gomega.Expect(config.Engine.Platform).To(gomega.Equal("linux/amd64")) gomega.Expect(config.Engine.PlatformToOCIRuntime).To(gomega.Equal(PlatformToOCIRuntimeMap)) gomega.Expect(config.Engine.ImageDefaultFormat).To(gomega.Equal("v2s2")) gomega.Expect(config.Engine.CompressionFormat).To(gomega.BeEquivalentTo("zstd:chunked")) @@ -661,6 +663,68 @@ image_copy_tmp_dir="storage"` err = defConf.Engine.Validate() gomega.Expect(err).To(gomega.HaveOccurred()) }) + + It("should succeed with valid platform", func() { + defConf, err := defaultConfig() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + defConf.Engine.Platform = "linux/amd64" + err = defConf.Engine.Validate() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + defConf.Engine.Platform = "linux/arm64/v8" + err = defConf.Engine.Validate() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + }) + + It("should succeed with empty platform", func() { + defConf, err := defaultConfig() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + defConf.Engine.Platform = "" + err = defConf.Engine.Validate() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + }) + + It("should fail with invalid platform", func() { + defConf, err := defaultConfig() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + defConf.Engine.Platform = "linux" + err = defConf.Engine.Validate() + gomega.Expect(err).To(gomega.HaveOccurred()) + + defConf.Engine.Platform = "linux/amd64/" + err = defConf.Engine.Validate() + gomega.Expect(err).To(gomega.HaveOccurred()) + + defConf.Engine.Platform = "/amd64" + err = defConf.Engine.Validate() + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + + It("should parse platform components correctly", func() { + defConf, err := defaultConfig() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + defConf.Engine.Platform = "linux/amd64" + os, arch, variant := defConf.Engine.PlatformComponents() + gomega.Expect(os).To(gomega.Equal("linux")) + gomega.Expect(arch).To(gomega.Equal("amd64")) + gomega.Expect(variant).To(gomega.Equal("")) + + defConf.Engine.Platform = "linux/arm64/v8" + os, arch, variant = defConf.Engine.PlatformComponents() + gomega.Expect(os).To(gomega.Equal("linux")) + gomega.Expect(arch).To(gomega.Equal("arm64")) + gomega.Expect(variant).To(gomega.Equal("v8")) + + defConf.Engine.Platform = "" + os, arch, variant = defConf.Engine.PlatformComponents() + gomega.Expect(os).To(gomega.Equal("")) + gomega.Expect(arch).To(gomega.Equal("")) + gomega.Expect(variant).To(gomega.Equal("")) + }) }) Describe("Service Destinations", func() { diff --git a/pkg/config/testdata/containers_override.conf b/pkg/config/testdata/containers_override.conf index a4a336098..04f2a6f6c 100644 --- a/pkg/config/testdata/containers_override.conf +++ b/pkg/config/testdata/containers_override.conf @@ -18,6 +18,7 @@ events_container_create_inspect_data = true pod_exit_policy="stop" compression_format="zstd:chunked" cdi_spec_dirs = [ "/somepath" ] +platform = "linux/amd64" [engine.platform_to_oci_runtime] hello = "world"