log: expose conmon log rotation options through REST API and CLI

As of conmon 2.2.0, conmon supports log file rotation via --log-rotate
and --log-max-files. This commit exposes those options through Podman's
REST API, specgen model, CLI (--log-opt), and libpod container runtime
so that users managing containers through the API can configure conmon
log rotation settings.

New --log-opt keys accepted:
  max-file=<n>               Maximum number of rotated log backup files.
                             Setting this implicitly enables log rotation.
  log-rotate=true|false      Explicitly enable or disable log rotation.

Options flow:
  CLI / REST API
    -> specgenutil parses and validates new opts
    -> specgen.LogConfig.Options carries max-file and log-rotate
    -> generate.MakeContainerFromSpec calls WithLogMaxFiles/WithLogRotate
    -> libpod stores LogMaxFiles and LogRotate in ContainerConfig
    -> oci_conmon_common.go passes --log-max-files/--log-rotate to conmon

podman inspect now reflects rotation settings in HostConfig.LogConfig.Config.

Fixes: https://github.com/containers/podman/issues/29543
Signed-off-by: prabhat-kumar96 <prabhatmathur9927@gmail.com>
This commit is contained in:
prabhat-kumar96 committed 2026-10-24 15:44:16 +05:30
1 parent eeeb322d33
commit 385f0905e9
13 files changed
+427 -3

No files matched your search

+2 -2
View File
@@ -1170,9 +1170,9 @@ func AutocompleteLogDriver(_ *cobra.Command, _ []string, _ string) ([]string, co
}
// AutocompleteLogOpt - Autocomplete log-opt options.
// -> "path=", "tag="
// -> "path=", "tag=", "max-size=", "max-file=", "log-rotate=", "label="
func AutocompleteLogOpt(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
logOptions := []string{"path=", "tag=", "max-size=", "label="}
logOptions := []string{"path=", "tag=", "max-size=", "max-file=", "log-rotate=", "label="}
if strings.HasPrefix(toComplete, "path=") {
return nil, cobra.ShellCompDirectiveDefault
}
+11
View File
@@ -18,6 +18,17 @@ Set custom logging configuration. The following *name*s are supported:
**max-size**: specify a max size of the log file
(e.g. << '**LogOpt=max-size=10mb**' if is_quadlet else '**--log-opt max-size=10mb**' >>);
**max-file**: specify the maximum number of rotated log files to keep when log
rotation is enabled (requires conmon >= 2.2.0)
(e.g. << '**LogOpt=max-file=5**' if is_quadlet else '**--log-opt max-file=5**' >>).
Setting this option implicitly enables log rotation. If log rotation is enabled
without setting **max-file**, conmon keeps 1 backup file by default.
**log-rotate**: enable or disable log rotation for the container log file
(requires conmon >= 2.2.0). When enabled, the log file is rotated with a numbered
suffix instead of being truncated when it reaches the maximum size
(e.g. << '**LogOpt=log-rotate=true**' if is_quadlet else '**--log-opt log-rotate=true**' >>).
**tag**: specify a custom log tag for the container
(e.g. << '**LogOpt=tag="{{.ImageName}}"**' if is_quadlet else '**--log-opt tag="{{.ImageName}}"**' >>.
It supports the same keys as **podman inspect --format**.
+10
View File
@@ -674,6 +674,16 @@ func (c *Container) LogLabels() map[string]string {
return c.config.LogLabels
}
// LogRotate returns whether log rotation is enabled for the container's log file.
func (c *Container) LogRotate() bool {
return c.config.LogRotate
}
// LogMaxFiles returns the maximum number of rotated log files kept for the container.
func (c *Container) LogMaxFiles() uint {
return c.config.LogMaxFiles
}
// RestartPolicy returns the container's restart policy.
func (c *Container) RestartPolicy() string {
return c.config.RestartPolicy
+6
View File
@@ -381,6 +381,12 @@ type ContainerMiscConfig struct {
LogSize int64 `json:"logSize"`
// LogDriver driver for logs
LogDriver string `json:"logDriver"`
// LogRotate indicates whether log files should be rotated instead of
// truncated when the maximum size is reached.
LogRotate bool `json:"logRotate,omitempty"`
// LogMaxFiles is the maximum number of rotated log files to keep when
// log rotation is enabled (LogRotate == true).
LogMaxFiles uint `json:"logMaxFiles,omitempty"`
// File containing the conmon PID
ConmonPidFile string `json:"conmonPidFile,omitempty"`
// RestartPolicy indicates what action the container will take upon
+14
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"maps"
"slices"
"strconv"
"strings"
"github.com/docker/go-units"
@@ -496,6 +497,19 @@ func (c *Container) generateInspectContainerHostConfig(ctrSpec *spec.Spec, named
logConfig.Size = units.HumanSize(float64(c.LogSizeMax()))
logConfig.Tag = c.config.LogTag
// Populate Config map with log rotation settings when configured.
if c.config.LogRotate || c.config.LogMaxFiles > 0 {
if logConfig.Config == nil {
logConfig.Config = make(map[string]string)
}
if c.config.LogRotate {
logConfig.Config["log-rotate"] = "true"
}
if c.config.LogMaxFiles > 0 {
logConfig.Config["max-file"] = strconv.FormatUint(uint64(c.config.LogMaxFiles), 10)
}
}
hostConfig.LogConfig = logConfig
restartPolicy := new(define.InspectRestartPolicy)
+10
View File
@@ -1389,6 +1389,16 @@ func (r *ConmonOCIRuntime) sharedConmonArgs(ctr *Container, cuuid, bundlePath, p
args = append(args, "--log-size-max", strconv.FormatInt(size, 10))
}
// Log rotation options: --log-rotate enables rotation; --log-max-files
// sets the number of backup files to keep.
// If max-file is not specified, conmon keeps 1 backup file by default when --log-rotate is passed.
if ctr.LogRotate() {
args = append(args, "--log-rotate")
}
if logMaxFiles := ctr.LogMaxFiles(); logMaxFiles > 0 {
args = append(args, "--log-max-files", strconv.FormatUint(uint64(logMaxFiles), 10))
}
if ociLogPath != "" {
args = append(args, "--runtime-arg", "--log-format=json", "--runtime-arg", "--log", fmt.Sprintf("--runtime-arg=%s", ociLogPath))
}
+28
View File
@@ -1065,6 +1065,34 @@ func WithLogLabels(logLabels map[string]string) CtrCreateOption {
}
}
// WithLogRotate sets whether the container's log file should be rotated
// instead of truncated when the maximum size is reached.
func WithLogRotate(rotate bool) CtrCreateOption {
return func(ctr *Container) error {
if ctr.valid {
return define.ErrCtrFinalized
}
ctr.config.LogRotate = rotate
return nil
}
}
// WithLogMaxFiles sets the maximum number of rotated log files to keep.
// This is only meaningful when log rotation is enabled.
func WithLogMaxFiles(maxFiles uint) CtrCreateOption {
return func(ctr *Container) error {
if ctr.valid {
return define.ErrCtrFinalized
}
ctr.config.LogMaxFiles = maxFiles
return nil
}
}
// WithCgroupsMode disables the creation of Cgroups for the conmon process.
func WithCgroupsMode(mode string) CtrCreateOption {
return func(ctr *Container) error {
+43
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/jinzhu/copier"
@@ -597,7 +598,49 @@ func createContainerOptions(rt *libpod.Runtime, s *specgen.SpecGenerator, pod *l
if len(s.LogConfiguration.Driver) > 0 {
options = append(options, libpod.WithLogDriver(s.LogConfiguration.Driver))
}
// Log rotation options: accepted as --log-opt max-file=<n> or
// --log-opt log-rotate=true|false from the CLI or REST API.
if len(s.LogConfiguration.Options) > 0 {
var logMaxFiles uint
var logRotate *bool
if rotateStr, ok := s.LogConfiguration.Options["log-rotate"]; ok {
rotate, err := strconv.ParseBool(rotateStr)
if err != nil {
return nil, fmt.Errorf("invalid log option log-rotate %q: %w", rotateStr, err)
}
logRotate = &rotate
}
if maxFileStr, ok := s.LogConfiguration.Options["max-file"]; ok {
n, err := strconv.ParseUint(maxFileStr, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid log option max-file %q: %w", maxFileStr, err)
}
if n == 0 {
return nil, fmt.Errorf("invalid log option max-file %q: must be greater than 0", maxFileStr)
}
logMaxFiles = uint(n)
}
if logMaxFiles > 0 {
if logRotate != nil && !*logRotate {
return nil, fmt.Errorf("conflicting log options: max-file cannot be set when log-rotate is false")
}
options = append(options, libpod.WithLogMaxFiles(logMaxFiles))
// Setting max-file implicitly enables log rotation unless explicitly specified.
if logRotate == nil {
trueVal := true
logRotate = &trueVal
}
}
if logRotate != nil {
options = append(options, libpod.WithLogRotate(*logRotate))
}
}
}
if s.LabelNested != nil {
options = append(options, libpod.WithLabelNested(*s.LabelNested))
}
+23
View File
@@ -896,6 +896,24 @@ func FillOutSpecGen(s *specgen.SpecGenerator, c *entities.ContainerCreateOptions
return err
}
s.LogConfiguration.Size = logSize
case "max-file":
// Validate that the value is a positive integer greater than 0,
// then store it under the canonical key so container_create.go can consume it.
maxFiles, err := strconv.ParseUint(val, 10, 64)
if err != nil {
return fmt.Errorf("invalid value for log option %q: must be a positive integer: %w", key, err)
}
if maxFiles == 0 {
return fmt.Errorf("invalid value for log option %q: must be greater than 0", key)
}
logOpts["max-file"] = val
case "log-rotate":
// Validate that the value is a boolean, then store it under the
// canonical key so container_create.go can consume it.
if _, err := strconv.ParseBool(val); err != nil {
return fmt.Errorf("invalid value for log option %q: must be true or false: %w", key, err)
}
logOpts["log-rotate"] = val
case "label":
labelKey, labelVal, hasVal := strings.Cut(val, "=")
if !hasVal {
@@ -908,6 +926,11 @@ func FillOutSpecGen(s *specgen.SpecGenerator, c *entities.ContainerCreateOptions
}
if len(s.LogConfiguration.Options) == 0 || len(c.LogOptions) != 0 {
if mf, ok := logOpts["max-file"]; ok && mf != "" {
if lr, ok := logOpts["log-rotate"]; ok && lr == "false" {
return fmt.Errorf("conflicting log options: max-file cannot be set when log-rotate is false")
}
}
s.LogConfiguration.Options = logOpts
}
if len(s.LogConfiguration.Labels) == 0 || len(c.LogOptions) != 0 {
+106
View File
@@ -231,3 +231,109 @@ func TestFillOutSpecGenRecorsUserNs(t *testing.T) {
assert.True(t, ok, "UserNsAnnotation is set")
assert.Equal(t, "keep-id", v, "UserNsAnnotation is keep-id")
}
func TestLogOptMaxFile(t *testing.T) {
tests := []struct {
desc string
logOpts []string
wantVal string
wantErr bool
}{
{
desc: "valid max-file integer",
logOpts: []string{"max-file=5"},
wantVal: "5",
},
{
desc: "max-file of 1",
logOpts: []string{"max-file=1"},
wantVal: "1",
},
{
desc: "invalid max-file string",
logOpts: []string{"max-file=notanumber"},
wantErr: true,
},
{
desc: "invalid max-file negative",
logOpts: []string{"max-file=-3"},
wantErr: true,
},
{
desc: "invalid max-file zero",
logOpts: []string{"max-file=0"},
wantErr: true,
},
{
desc: "conflicting max-file with log-rotate=false",
logOpts: []string{"max-file=5", "log-rotate=false"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
sg := specgen.NewSpecGenerator("nothing", false)
sg.LogConfiguration = &specgen.LogConfig{}
err := FillOutSpecGen(sg, &entities.ContainerCreateOptions{
ImageVolume: "ignore",
LogOptions: tt.logOpts,
}, []string{})
if tt.wantErr {
assert.Error(t, err, "expected an error for %q", tt.logOpts)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantVal, sg.LogConfiguration.Options["max-file"],
"max-file stored in LogConfiguration.Options")
})
}
}
func TestLogOptLogRotate(t *testing.T) {
tests := []struct {
desc string
logOpts []string
wantVal string
wantErr bool
}{
{
desc: "log-rotate true",
logOpts: []string{"log-rotate=true"},
wantVal: "true",
},
{
desc: "log-rotate false",
logOpts: []string{"log-rotate=false"},
wantVal: "false",
},
{
desc: "log-rotate 1",
logOpts: []string{"log-rotate=1"},
wantVal: "1",
},
{
desc: "invalid log-rotate value",
logOpts: []string{"log-rotate=maybe"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
sg := specgen.NewSpecGenerator("nothing", false)
sg.LogConfiguration = &specgen.LogConfig{}
err := FillOutSpecGen(sg, &entities.ContainerCreateOptions{
ImageVolume: "ignore",
LogOptions: tt.logOpts,
}, []string{})
if tt.wantErr {
assert.Error(t, err, "expected an error for %q", tt.logOpts)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantVal, sg.LogConfiguration.Options["log-rotate"],
"log-rotate stored in LogConfiguration.Options")
})
}
}
+1 -1
View File
@@ -99,7 +99,7 @@ BuildRequires: sqlite-devel
BuildRequires: systemd
BuildRequires: systemd-devel
Requires: catatonit
Requires: conmon >= 2:2.1.7-2
Requires: conmon >= 2:2.2.0
# Podman 6 requires the new config file layout.
Requires: containers-common-extra >= 5:0.68.0
%if %{defined sequoia}
@@ -438,6 +438,62 @@ class ContainerTestCase(APITestCase):
self.assertEqual(2000, out["HostConfig"]["MemorySwap"])
self.assertEqual(1000, out["HostConfig"]["Memory"])
def test_log_rotation(self):
# Libpod create endpoint with log rotation options in SpecGenerator
r = requests.post(
self.podman_url + "/v1.4.0/libpod/containers/create",
json={
"image": "alpine:latest",
"name": "log_rotate_libpod",
"command": ["top"],
"log_configuration": {
"driver": "k8s-file",
"options": {
"max-file": "5",
"log-rotate": "true",
},
},
},
)
self.assertEqual(r.status_code, 201, r.text)
payload = r.json()
container_id = payload["Id"]
self.assertIsNotNone(container_id)
r = requests.get(self.podman_url + f"/v1.40/containers/{container_id}/json")
self.assertEqual(r.status_code, 200, r.text)
out = r.json()
self.assertEqual("5", out["HostConfig"]["LogConfig"]["Config"]["max-file"])
self.assertEqual("true", out["HostConfig"]["LogConfig"]["Config"]["log-rotate"])
# Compat create endpoint with log rotation options in HostConfig.LogConfig
r = requests.post(
self.podman_url + "/v1.40/containers/create?name=log_rotate_compat",
json={
"Image": "alpine:latest",
"Cmd": ["top"],
"HostConfig": {
"LogConfig": {
"Type": "k8s-file",
"Config": {
"max-file": "3",
"log-rotate": "true",
},
},
},
},
)
self.assertEqual(r.status_code, 201, r.text)
payload = r.json()
container_id = payload["Id"]
self.assertIsNotNone(container_id)
r = requests.get(self.podman_url + f"/v1.40/containers/{container_id}/json")
self.assertEqual(r.status_code, 200, r.text)
out = r.json()
self.assertEqual("3", out["HostConfig"]["LogConfig"]["Config"]["max-file"])
self.assertEqual("true", out["HostConfig"]["LogConfig"]["Config"]["log-rotate"])
def test_host_config_port_bindings(self):
# create a container with two ports exposed, but only one of the ports bound
r = requests.post(
+117
View File
@@ -0,0 +1,117 @@
//go:build linux || freebsd
package integration
import (
"fmt"
"os"
"path/filepath"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
. "go.podman.io/podman/v6/test/utils"
)
var _ = Describe("Podman log rotation options", func() {
It("podman run --log-opt max-file sets rotation file count", func() {
SkipIfConmonVersionLessThan("2.2.0")
logDir := GinkgoT().TempDir()
logPath := filepath.Join(logDir, "ctr.log")
podmanTest.PodmanExitCleanly(
"run", "--rm",
"--log-driver", "k8s-file",
"--log-opt", fmt.Sprintf("path=%s", logPath),
"--log-opt", "max-file=3",
ALPINE, "echo", "hello rotation",
)
// The log file must exist after the container exits.
_, err := os.Stat(logPath)
Expect(err).ShouldNot(HaveOccurred(), "log file should exist at %s", logPath)
// Verify max-file appears in podman inspect output.
ctrName := "log-rotate-inspect"
podmanTest.PodmanExitCleanly(
"create",
"--name", ctrName,
"--log-driver", "k8s-file",
"--log-opt", "max-size=1mb",
"--log-opt", "max-file=5",
ALPINE, "echo", "hello",
)
inspect := podmanTest.PodmanExitCleanly(
"inspect", "--format",
"{{index .HostConfig.LogConfig.Config \"max-file\"}}",
ctrName,
)
Expect(inspect.OutputToString()).To(Equal("5"))
inspectRotate := podmanTest.PodmanExitCleanly(
"inspect", "--format",
"{{index .HostConfig.LogConfig.Config \"log-rotate\"}}",
ctrName,
)
Expect(inspectRotate.OutputToString()).To(Equal("true"))
})
It("podman run --log-opt log-rotate=true sets log rotation", func() {
ctrName := "log-rotate-true"
podmanTest.PodmanExitCleanly(
"create",
"--name", ctrName,
"--log-driver", "k8s-file",
"--log-opt", "max-size=1mb",
"--log-opt", "log-rotate=true",
ALPINE, "echo", "hello",
)
inspect := podmanTest.PodmanExitCleanly(
"inspect", "--format",
"{{index .HostConfig.LogConfig.Config \"log-rotate\"}}",
ctrName,
)
Expect(inspect.OutputToString()).To(Equal("true"))
})
It("podman run rejects conflicting --log-opt max-file and log-rotate=false", func() {
session := podmanTest.Podman([]string{
"run", "--rm",
"--log-opt", "max-file=5",
"--log-opt", "log-rotate=false",
ALPINE, "true",
})
session.WaitWithDefaultTimeout()
Expect(session).Should(ExitWithError(125, "conflicting log options"))
})
It("podman run --log-opt log-rotate=true without max-size", func() {
ctrName := "log-rotate-no-maxsize"
podmanTest.PodmanExitCleanly(
"create",
"--name", ctrName,
"--log-driver", "k8s-file",
"--log-opt", "log-rotate=true",
ALPINE, "echo", "hello",
)
inspect := podmanTest.PodmanExitCleanly(
"inspect", "--format",
"{{index .HostConfig.LogConfig.Config \"log-rotate\"}}",
ctrName,
)
Expect(inspect.OutputToString()).To(Equal("true"))
})
It("podman run rejects invalid --log-opt max-file=0", func() {
session := podmanTest.Podman([]string{
"run", "--rm",
"--log-opt", "max-file=0",
ALPINE, "true",
})
session.WaitWithDefaultTimeout()
Expect(session).Should(ExitWithError(125, "invalid value for log option"))
})
})