From 9dbe094eacb840b9d9d53fec388d2c336d194bd5 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 15 Oct 2025 11:10:27 +0200 Subject: [PATCH 1/2] Set secret/config uid:gid to match container's USER Signed-off-by: Nicolas De Loof --- pkg/compose/run.go | 9 ++++-- pkg/compose/secrets.go | 40 ++++++++++++++++++++++++ pkg/e2e/fixtures/env-secret/compose.yaml | 17 ++++++++++ pkg/e2e/secrets_test.go | 15 +++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/pkg/compose/run.go b/pkg/compose/run.go index 242f2fc1cce..0e454e6a2f3 100644 --- a/pkg/compose/run.go +++ b/pkg/compose/run.go @@ -133,12 +133,17 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project, return "", err } - err = s.injectSecrets(ctx, project, service, created.ID) + ctr, err := s.apiClient().ContainerInspect(ctx, created.ID) + if err != nil { + return "", err + } + + err = s.injectSecrets(ctx, project, service, ctr.ID) if err != nil { return created.ID, err } - err = s.injectConfigs(ctx, project, service, created.ID) + err = s.injectConfigs(ctx, project, service, ctr.ID) return created.ID, err } diff --git a/pkg/compose/secrets.go b/pkg/compose/secrets.go index e8064cca8b2..bd5ca1a90a7 100644 --- a/pkg/compose/secrets.go +++ b/pkg/compose/secrets.go @@ -22,6 +22,7 @@ import ( "context" "fmt" "strconv" + "strings" "time" "github.com/compose-spec/compose-go/v2/types" @@ -29,6 +30,7 @@ import ( ) func (s *composeService) injectSecrets(ctx context.Context, project *types.Project, service types.ServiceConfig, id string) error { + var ctrConfig *container.Config for _, config := range service.Secrets { file := project.Secrets[config.Source] if file.Environment == "" { @@ -53,6 +55,25 @@ func (s *composeService) injectSecrets(ctx context.Context, project *types.Proje } content = env } + + if config.UID == "" && config.GID == "" { + if ctrConfig == nil { + ctr, err := s.apiClient().ContainerInspect(ctx, id) + if err != nil { + return err + } + ctrConfig = ctr.Config + } + + parts := strings.Split(ctrConfig.User, ":") + if len(parts) > 0 { + config.UID = parts[0] + } + if len(parts) > 1 { + config.GID = parts[1] + } + } + b, err := createTar(content, types.FileReferenceConfig(config)) if err != nil { return err @@ -69,6 +90,7 @@ func (s *composeService) injectSecrets(ctx context.Context, project *types.Proje } func (s *composeService) injectConfigs(ctx context.Context, project *types.Project, service types.ServiceConfig, id string) error { + var ctrConfig *container.Config for _, config := range service.Configs { file := project.Configs[config.Source] content := file.Content @@ -91,6 +113,24 @@ func (s *composeService) injectConfigs(ctx context.Context, project *types.Proje config.Target = "/" + config.Source } + if config.UID == "" && config.GID == "" { + if ctrConfig == nil { + ctr, err := s.apiClient().ContainerInspect(ctx, id) + if err != nil { + return err + } + ctrConfig = ctr.Config + } + + parts := strings.Split(ctrConfig.User, ":") + if len(parts) > 0 { + config.UID = parts[0] + } + if len(parts) > 1 { + config.GID = parts[1] + } + } + b, err := createTar(content, types.FileReferenceConfig(config)) if err != nil { return err diff --git a/pkg/e2e/fixtures/env-secret/compose.yaml b/pkg/e2e/fixtures/env-secret/compose.yaml index 51052d36d21..ef272419a40 100644 --- a/pkg/e2e/fixtures/env-secret/compose.yaml +++ b/pkg/e2e/fixtures/env-secret/compose.yaml @@ -14,6 +14,23 @@ services: mode: 0440 command: cat /run/secrets/bar + bar: + image: alpine + user: "1005" + secrets: + - source: secret + target: bar + command: cat /run/secrets/bar + + zot: + image: alpine + user: "1005:1005" + secrets: + - source: secret + target: bar + command: cat /run/secrets/bar + + secrets: secret: environment: SECRET diff --git a/pkg/e2e/secrets_test.go b/pkg/e2e/secrets_test.go index 3e3895112a3..febfdd2b7ab 100644 --- a/pkg/e2e/secrets_test.go +++ b/pkg/e2e/secrets_test.go @@ -40,6 +40,21 @@ func TestSecretFromEnv(t *testing.T) { }) res.Assert(t, icmd.Expected{Out: "-r--r----- 1 1005 1005"}) }) + t.Run("secret uid from user", func(t *testing.T) { + res := icmd.RunCmd(c.NewDockerComposeCmd(t, "-f", "./fixtures/env-secret/compose.yaml", "run", "bar", "ls", "-al", "/var/run/secrets/bar"), + func(cmd *icmd.Cmd) { + cmd.Env = append(cmd.Env, "SECRET=BAR") + }) + res.Assert(t, icmd.Expected{Out: "-r--r--r-- 1 1005 root"}) + }) + t.Run("secret uid:gid from user", func(t *testing.T) { + res := icmd.RunCmd(c.NewDockerComposeCmd(t, "-f", "./fixtures/env-secret/compose.yaml", "run", "zot", "ls", "-al", "/var/run/secrets/bar"), + func(cmd *icmd.Cmd) { + cmd.Env = append(cmd.Env, "SECRET=BAR") + }) + res.Assert(t, icmd.Expected{Out: "-r--r--r-- 1 1005 1005"}) + }) + } func TestSecretFromInclude(t *testing.T) { From 581674ec37a9c47c2bc262596b4dfd2a03b510a8 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 15 Oct 2025 11:41:59 +0200 Subject: [PATCH 2/2] mutualize code from injectSecrets / injectConfigs Signed-off-by: Nicolas De Loof --- pkg/compose/secrets.go | 186 ++++++++++++++++++++++------------------ pkg/e2e/secrets_test.go | 14 ++- 2 files changed, 113 insertions(+), 87 deletions(-) diff --git a/pkg/compose/secrets.go b/pkg/compose/secrets.go index bd5ca1a90a7..42ccd4e76b8 100644 --- a/pkg/compose/secrets.go +++ b/pkg/compose/secrets.go @@ -29,121 +29,139 @@ import ( "github.com/docker/docker/api/types/container" ) +type mountType string + +const ( + secretMount mountType = "secret" + configMount mountType = "config" +) + func (s *composeService) injectSecrets(ctx context.Context, project *types.Project, service types.ServiceConfig, id string) error { - var ctrConfig *container.Config - for _, config := range service.Secrets { - file := project.Secrets[config.Source] - if file.Environment == "" { - continue - } + return s.injectFileReferences(ctx, project, service, id, secretMount) +} - if service.ReadOnly { - return fmt.Errorf("cannot create secret %q in read-only service %s: `file` is the sole supported option", file.Name, service.Name) - } +func (s *composeService) injectConfigs(ctx context.Context, project *types.Project, service types.ServiceConfig, id string) error { + return s.injectFileReferences(ctx, project, service, id, configMount) +} - if config.Target == "" { - config.Target = "/run/secrets/" + config.Source - } else if !isAbsTarget(config.Target) { - config.Target = "/run/secrets/" + config.Target - } +func (s *composeService) injectFileReferences(ctx context.Context, project *types.Project, service types.ServiceConfig, id string, mountType mountType) error { + mounts, sources := s.getFilesAndMap(project, service, mountType) + var ctrConfig *container.Config - content := file.Content + for _, mount := range mounts { + content, err := s.resolveFileContent(project, sources[mount.Source], mountType) + if err != nil { + return err + } if content == "" { - env, ok := project.Environment[file.Environment] - if !ok { - return fmt.Errorf("environment variable %q required by secret %q is not set", file.Environment, file.Name) - } - content = env + continue } - if config.UID == "" && config.GID == "" { - if ctrConfig == nil { - ctr, err := s.apiClient().ContainerInspect(ctx, id) - if err != nil { - return err - } - ctrConfig = ctr.Config - } - - parts := strings.Split(ctrConfig.User, ":") - if len(parts) > 0 { - config.UID = parts[0] - } - if len(parts) > 1 { - config.GID = parts[1] - } + if service.ReadOnly { + return fmt.Errorf("cannot create %s %q in read-only service %s: `file` is the sole supported option", mountType, sources[mount.Source].Name, service.Name) } - b, err := createTar(content, types.FileReferenceConfig(config)) + s.setDefaultTarget(&mount, mountType) + + ctrConfig, err = s.setFileOwnership(ctx, id, &mount, ctrConfig) if err != nil { return err } - err = s.apiClient().CopyToContainer(ctx, id, "/", &b, container.CopyToContainerOptions{ - CopyUIDGID: config.UID != "" || config.GID != "", - }) - if err != nil { + if err := s.copyFileToContainer(ctx, id, content, mount); err != nil { return err } } return nil } -func (s *composeService) injectConfigs(ctx context.Context, project *types.Project, service types.ServiceConfig, id string) error { - var ctrConfig *container.Config - for _, config := range service.Configs { - file := project.Configs[config.Source] - content := file.Content - if file.Environment != "" { - env, ok := project.Environment[file.Environment] - if !ok { - return fmt.Errorf("environment variable %q required by config %q is not set", file.Environment, file.Name) - } - content = env +func (s *composeService) getFilesAndMap(project *types.Project, service types.ServiceConfig, mountType mountType) ([]types.FileReferenceConfig, map[string]types.FileObjectConfig) { + var files []types.FileReferenceConfig + var fileMap map[string]types.FileObjectConfig + + switch mountType { + case secretMount: + files = make([]types.FileReferenceConfig, len(service.Secrets)) + for i, config := range service.Secrets { + files[i] = types.FileReferenceConfig(config) } - if content == "" { - continue + fileMap = make(map[string]types.FileObjectConfig) + for k, v := range project.Secrets { + fileMap[k] = types.FileObjectConfig(v) } - - if service.ReadOnly { - return fmt.Errorf("cannot create config %q in read-only service %s: `file` is the sole supported option", file.Name, service.Name) + case configMount: + files = make([]types.FileReferenceConfig, len(service.Configs)) + for i, config := range service.Configs { + files[i] = types.FileReferenceConfig(config) } - - if config.Target == "" { - config.Target = "/" + config.Source + fileMap = make(map[string]types.FileObjectConfig) + for k, v := range project.Configs { + fileMap[k] = types.FileObjectConfig(v) } + } + return files, fileMap +} - if config.UID == "" && config.GID == "" { - if ctrConfig == nil { - ctr, err := s.apiClient().ContainerInspect(ctx, id) - if err != nil { - return err - } - ctrConfig = ctr.Config - } - - parts := strings.Split(ctrConfig.User, ":") - if len(parts) > 0 { - config.UID = parts[0] - } - if len(parts) > 1 { - config.GID = parts[1] - } +func (s *composeService) resolveFileContent(project *types.Project, source types.FileObjectConfig, mountType mountType) (string, error) { + if source.Content != "" { + // inlined, or already resolved by include + return source.Content, nil + } + if source.Environment != "" { + env, ok := project.Environment[source.Environment] + if !ok { + return "", fmt.Errorf("environment variable %q required by %s %q is not set", source.Environment, mountType, source.Name) } + return env, nil + } + return "", nil +} - b, err := createTar(content, types.FileReferenceConfig(config)) - if err != nil { - return err +func (s *composeService) setDefaultTarget(file *types.FileReferenceConfig, mountType mountType) { + if file.Target == "" { + if mountType == secretMount { + file.Target = "/run/secrets/" + file.Source + } else { + file.Target = "/" + file.Source } + } else if mountType == secretMount && !isAbsTarget(file.Target) { + file.Target = "/run/secrets/" + file.Target + } +} - err = s.apiClient().CopyToContainer(ctx, id, "/", &b, container.CopyToContainerOptions{ - CopyUIDGID: config.UID != "" || config.GID != "", - }) +func (s *composeService) setFileOwnership(ctx context.Context, id string, file *types.FileReferenceConfig, ctrConfig *container.Config) (*container.Config, error) { + if file.UID != "" || file.GID != "" { + return ctrConfig, nil + } + + if ctrConfig == nil { + ctr, err := s.apiClient().ContainerInspect(ctx, id) if err != nil { - return err + return nil, err } + ctrConfig = ctr.Config } - return nil + + parts := strings.Split(ctrConfig.User, ":") + if len(parts) > 0 { + file.UID = parts[0] + } + if len(parts) > 1 { + file.GID = parts[1] + } + + return ctrConfig, nil +} + +func (s *composeService) copyFileToContainer(ctx context.Context, id, content string, file types.FileReferenceConfig) error { + b, err := createTar(content, file) + if err != nil { + return err + } + + return s.apiClient().CopyToContainer(ctx, id, "/", &b, container.CopyToContainerOptions{ + CopyUIDGID: true, + }) } func createTar(env string, config types.FileReferenceConfig) (bytes.Buffer, error) { diff --git a/pkg/e2e/secrets_test.go b/pkg/e2e/secrets_test.go index febfdd2b7ab..dde21061b36 100644 --- a/pkg/e2e/secrets_test.go +++ b/pkg/e2e/secrets_test.go @@ -17,6 +17,7 @@ package e2e import ( + "strings" "testing" "gotest.tools/v3/icmd" @@ -41,20 +42,27 @@ func TestSecretFromEnv(t *testing.T) { res.Assert(t, icmd.Expected{Out: "-r--r----- 1 1005 1005"}) }) t.Run("secret uid from user", func(t *testing.T) { - res := icmd.RunCmd(c.NewDockerComposeCmd(t, "-f", "./fixtures/env-secret/compose.yaml", "run", "bar", "ls", "-al", "/var/run/secrets/bar"), + res := c.RunDockerCmd(t, "version", "--format", "{{ .Server.Version }}") + if strings.HasPrefix(res.Stdout(), "27.") { + t.Skip("USER uid:gid is not supported") + } + res = icmd.RunCmd(c.NewDockerComposeCmd(t, "-f", "./fixtures/env-secret/compose.yaml", "run", "bar", "ls", "-al", "/var/run/secrets/bar"), func(cmd *icmd.Cmd) { cmd.Env = append(cmd.Env, "SECRET=BAR") }) res.Assert(t, icmd.Expected{Out: "-r--r--r-- 1 1005 root"}) }) t.Run("secret uid:gid from user", func(t *testing.T) { - res := icmd.RunCmd(c.NewDockerComposeCmd(t, "-f", "./fixtures/env-secret/compose.yaml", "run", "zot", "ls", "-al", "/var/run/secrets/bar"), + res := c.RunDockerCmd(t, "version", "--format", "{{ .Server.Version }}") + if strings.HasPrefix(res.Stdout(), "27.") { + t.Skip("USER uid:gid is not supported") + } + res = icmd.RunCmd(c.NewDockerComposeCmd(t, "-f", "./fixtures/env-secret/compose.yaml", "run", "zot", "ls", "-al", "/var/run/secrets/bar"), func(cmd *icmd.Cmd) { cmd.Env = append(cmd.Env, "SECRET=BAR") }) res.Assert(t, icmd.Expected{Out: "-r--r--r-- 1 1005 1005"}) }) - } func TestSecretFromInclude(t *testing.T) {