From bd4b0351a2e7ab26eab340019afc8e00318d9ce9 Mon Sep 17 00:00:00 2001 From: Naveenkumar Date: Sat, 5 Sep 2026 11:35:25 +0000 Subject: [PATCH] feat(provider): let provider extensions inject file-based secrets Provider extensions can currently only inject environment variables into dependent services (setenv/rawsetenv), but credentials are often better delivered as mounted files than env vars, which can leak through process inspection, debugging output, logs and crash reports. Add two new provider protocol messages, setsecret and rawsetsecret, mirroring setenv/rawsetenv: a provider can now hand a dependent service a secret whose content is mounted at /run/secrets/, without the compose file declaring it. Also fixes a latent bug in buildContainerSecretMounts: a secret with Content set (no File) was not skipped before building a bind mount, so it would bind-mount the process's current working directory into the container. Provider-contributed secrets are content-based, so this fix is required for the new feature to behave correctly. Closes #14163 Co-Authored-By: Claude Sonnet 5 Signed-off-by: Naveenkumar --- AI_AGENT_DISCLOSURE.md | 4 + docs/examples/provider.go | 2 + docs/extension.md | 28 ++++++ pkg/compose/create.go | 2 +- pkg/compose/plugins.go | 91 +++++++++++++++---- pkg/compose/plugins_test.go | 38 ++++++++ pkg/e2e/providers_test.go | 24 +++++ .../compose.yaml | 19 ++++ .../TestProviderSetSecret/compose.yaml | 13 +++ 9 files changed, 203 insertions(+), 18 deletions(-) create mode 100644 AI_AGENT_DISCLOSURE.md create mode 100644 pkg/e2e/testdata/TestProviderRawSetSecretOverridesUserSecret/compose.yaml create mode 100644 pkg/e2e/testdata/TestProviderSetSecret/compose.yaml diff --git a/AI_AGENT_DISCLOSURE.md b/AI_AGENT_DISCLOSURE.md new file mode 100644 index 00000000000..ca3cbb415d4 --- /dev/null +++ b/AI_AGENT_DISCLOSURE.md @@ -0,0 +1,4 @@ +> *"This contribution was prepared by an AI agent acting on a human's behalf. +> The human submitter may not have independently reviewed or tested the change."* + +2026-09-05 diff --git a/docs/examples/provider.go b/docs/examples/provider.go index 8fa5635e12b..ce79affb6b3 100644 --- a/docs/examples/provider.go +++ b/docs/examples/provider.go @@ -97,6 +97,8 @@ func up(options options, args []string) { } fmt.Printf(`{ "type": "setenv", "message": "URL=https://magic.cloud/%s" }%s`, servicename, lineSeparator) fmt.Printf(`{ "type": "rawsetenv", "message": "CLOUD_REGION=us-east-1" }%s`, lineSeparator) + fmt.Printf(`{ "type": "setsecret", "message": "db_password=hunter2" }%s`, lineSeparator) + fmt.Printf(`{ "type": "rawsetsecret", "message": "shared_api_key=raw-secret-value" }%s`, lineSeparator) } func down(_ *cobra.Command, _ []string) { diff --git a/docs/extension.md b/docs/extension.md index 268f56cc049..52239403bd1 100644 --- a/docs/extension.md +++ b/docs/extension.md @@ -58,6 +58,8 @@ JSON messages MUST include a `type` and a `message` attribute. - `error`: Lets the user know something went wrong with details about the error. Compose will render the message as the reason for the service failure. - `setenv`: Lets the plugin tell Compose how dependent services can access the created resource. The variable is automatically prefixed with the service name. See next section for further details. - `rawsetenv`: Same as `setenv`, but the variable is injected as-is without the service name prefix. Useful when applications require exact variable names that cannot be altered. +- `setsecret`: Lets the plugin hand dependent services a file-based secret instead of an environment variable. The secret name is automatically prefixed with the service name, and its content is mounted at `/run/secrets/` in the dependent service. Prefer this over `setenv` for credentials: environment variables can leak through process inspection, debugging output, logs and crash reports, while a mounted file does not. See next section for further details. +- `rawsetsecret`: Same as `setsecret`, but the secret name is used as-is without the service name prefix. Useful when applications require exact secret file names that cannot be altered, or to provide content for a secret the user already declared in the compose file. - `debug`: Those messages could help debugging the provider, but are not rendered to the user by default. They are rendered when Compose is started with `--verbose` flag. ```mermaid @@ -78,6 +80,32 @@ sequenceDiagram Compose-)Shell: service started ``` +## Secrets and mounts + +`setenv`/`rawsetenv` are convenient, but environment variables are a poor fit for credentials: they show up in `docker inspect`, process listings, debugging output and crash reports. `setsecret` and `rawsetsecret` let a provider deliver such values as a mounted file instead: + +```json +{ "type": "setsecret", "message": "db_password=hunter2" } +``` + +Given a provider service named `database`, this declares a project secret named `database_db_password` with `hunter2` as its content, and adds a reference to it on every dependent service — exactly as if the compose file had declared: + +```yaml +services: + app: + secrets: + - database_db_password +secrets: + database_db_password: + content: hunter2 +``` + +The secret is mounted read-only at `/run/secrets/database_db_password` in the `app` service, without the compose file ever declaring it. `rawsetsecret` behaves like `rawsetenv`: the name is used as-is, letting the provider target a secret name the application expects or that the user already declared in the compose file (in which case the provider's content wins, and Compose logs a warning — the same behavior `rawsetenv` has for environment variables). + +Both directives are additive to `setenv`/`rawsetenv`: a provider can emit any mix of the four message types to expose some values as environment variables and others as mounted secrets. + +> __Note:__ As with environment variables, the `compose up` provider command _MUST_ be idempotent: re-running it against an already-running resource must produce the same secret content, not rotate it on every `up`. + ## Connection to a service managed by a provider A service in the Compose application can declare dependency on a service managed by an external provider: diff --git a/pkg/compose/create.go b/pkg/compose/create.go index b30ce1a65c1..b2e04e64a1e 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -1275,7 +1275,7 @@ func buildContainerSecretMounts(p types.Project, s types.ServiceConfig) ([]mount return nil, errors.New("Docker Compose does not support secrets.*.template_driver") //nolint:staticcheck } - if definedSecret.Environment != "" { + if definedSecret.Environment != "" || definedSecret.Content != "" { continue } diff --git a/pkg/compose/plugins.go b/pkg/compose/plugins.go index 1514761b47d..d73cc55812a 100644 --- a/pkg/compose/plugins.go +++ b/pkg/compose/plugins.go @@ -49,13 +49,17 @@ const ( InfoType = "info" SetEnvType = "setenv" RawSetEnvType = "rawsetenv" + SetSecretType = "setsecret" + RawSetSecretType = "rawsetsecret" DebugType = "debug" providerMetadataDirectory = "compose/providers" ) type pluginVariables struct { - prefixed types.Mapping - raw types.Mapping + prefixed types.Mapping + raw types.Mapping + secrets types.Mapping + rawSecrets types.Mapping } var mux sync.Mutex @@ -87,24 +91,63 @@ func (s *composeService) runPlugin(ctx context.Context, project *types.Project, mux.Lock() defer mux.Unlock() - for name, s := range project.Services { - if _, ok := s.DependsOn[service.Name]; ok { - prefix := strings.ToUpper(service.Name) + "_" - for key, val := range variables.prefixed { - s.Environment[prefix+key] = &val - } - for key, val := range variables.raw { - if existing, ok := s.Environment[key]; ok && (existing == nil || *existing != val) { - logrus.Warnf("provider %q overrides environment variable %q in service %q", service.Name, key, name) - } - s.Environment[key] = &val - } - project.Services[name] = s + if len(variables.secrets) > 0 || len(variables.rawSecrets) > 0 { + if project.Secrets == nil { + project.Secrets = types.Secrets{} + } + } + for name, dependent := range project.Services { + if _, ok := dependent.DependsOn[service.Name]; !ok { + continue } + project.Services[name] = applyProviderVariables(project, service.Name, name, dependent, variables) } return nil } +// applyProviderVariables merges a provider's env vars and secrets into one +// dependent service, mirroring how setenv/rawsetenv/setsecret/rawsetsecret +// are documented to behave (docs/extension.md). +func applyProviderVariables(project *types.Project, providerName, dependentName string, dependent types.ServiceConfig, variables pluginVariables) types.ServiceConfig { + prefix := strings.ToUpper(providerName) + "_" + for key, val := range variables.prefixed { + dependent.Environment[prefix+key] = &val + } + for key, val := range variables.raw { + if existing, ok := dependent.Environment[key]; ok && (existing == nil || *existing != val) { + logrus.Warnf("provider %q overrides environment variable %q in service %q", providerName, key, dependentName) + } + dependent.Environment[key] = &val + } + + secretPrefix := providerName + "_" + for key, val := range variables.secrets { + dependent.Secrets = upsertProviderSecret(project, dependent.Secrets, secretPrefix+key, val) + } + for key, val := range variables.rawSecrets { + if existing, ok := project.Secrets[key]; ok && existing.Content != val { + logrus.Warnf("provider %q overrides secret %q in service %q", providerName, key, dependentName) + } + dependent.Secrets = upsertProviderSecret(project, dependent.Secrets, key, val) + } + return dependent +} + +// upsertProviderSecret declares (or updates) a project-level secret carrying +// content contributed by a provider, and ensures the dependent service +// references it — mirroring how setenv/rawsetenv mutate the environment map, +// but for the service's Secrets list, which has no natural key to overwrite +// in place. +func upsertProviderSecret(project *types.Project, secrets []types.ServiceSecretConfig, name, content string) []types.ServiceSecretConfig { + project.Secrets[name] = types.SecretConfig{Name: name, Content: content} + for _, ref := range secrets { + if ref.Source == name { + return secrets + } + } + return append(secrets, types.ServiceSecretConfig{Source: name}) +} + func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service types.ServiceConfig) (pluginVariables, error) { var action string switch command { @@ -135,8 +178,10 @@ func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service ty defer func() { _ = stdout.Close() }() variables := pluginVariables{ - prefixed: types.Mapping{}, - raw: types.Mapping{}, + prefixed: types.Mapping{}, + raw: types.Mapping{}, + secrets: types.Mapping{}, + rawSecrets: types.Mapping{}, } for { @@ -166,6 +211,18 @@ func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service ty return pluginVariables{}, fmt.Errorf("invalid response from plugin: %s", msg.Message) } variables.raw[key] = val + case SetSecretType: + key, val, found := strings.Cut(msg.Message, "=") + if !found { + return pluginVariables{}, fmt.Errorf("invalid response from plugin: %s", msg.Message) + } + variables.secrets[key] = val + case RawSetSecretType: + key, val, found := strings.Cut(msg.Message, "=") + if !found { + return pluginVariables{}, fmt.Errorf("invalid response from plugin: %s", msg.Message) + } + variables.rawSecrets[key] = val case DebugType: logrus.Debugf("%s: %s", service.Name, msg.Message) default: diff --git a/pkg/compose/plugins_test.go b/pkg/compose/plugins_test.go index 9ff2f653c4c..b0a7fd3d7f5 100644 --- a/pkg/compose/plugins_test.go +++ b/pkg/compose/plugins_test.go @@ -18,8 +18,10 @@ package compose import ( "encoding/json" + "os/exec" "testing" + "github.com/compose-spec/compose-go/v2/types" "gotest.tools/v3/assert" ) @@ -105,3 +107,39 @@ func TestProviderMetadata_StopAdvertisedWithoutParameters(t *testing.T) { assert.NilError(t, err) assert.Assert(t, metadata.Stop != nil, "Stop should be non-nil when key present even with null parameters") } + +func TestExecutePlugin_ParsesSecretMessages(t *testing.T) { + script := `printf '%s\n' ` + + `'{"type":"setsecret","message":"db_password=hunter2"}' ` + + `'{"type":"rawsetsecret","message":"api_key=s3cr3t=withpadding"}'` + cmd := exec.Command("sh", "-c", script) + + s := &composeService{events: &ignore{}} + variables, err := s.executePlugin(cmd, "up", types.ServiceConfig{Name: "provider"}) + assert.NilError(t, err) + assert.Equal(t, variables.secrets["db_password"], "hunter2") + assert.Equal(t, variables.rawSecrets["api_key"], "s3cr3t=withpadding") +} + +func TestExecutePlugin_InvalidSecretMessage(t *testing.T) { + cmd := exec.Command("sh", "-c", `printf '%s\n' '{"type":"setsecret","message":"no-equals-sign"}'`) + + s := &composeService{events: &ignore{}} + _, err := s.executePlugin(cmd, "up", types.ServiceConfig{Name: "provider"}) + assert.ErrorContains(t, err, "invalid response from plugin") +} + +func TestUpsertProviderSecret(t *testing.T) { + project := &types.Project{Secrets: types.Secrets{}} + + secrets := upsertProviderSecret(project, nil, "database_db_password", "hunter2") + assert.Equal(t, len(secrets), 1) + assert.Equal(t, secrets[0].Source, "database_db_password") + assert.Equal(t, project.Secrets["database_db_password"].Content, "hunter2") + + // Re-running the provider (idempotent `up`) must update the content + // without appending a duplicate reference to the dependent service. + secrets = upsertProviderSecret(project, secrets, "database_db_password", "rotated") + assert.Equal(t, len(secrets), 1) + assert.Equal(t, project.Secrets["database_db_password"].Content, "rotated") +} diff --git a/pkg/e2e/providers_test.go b/pkg/e2e/providers_test.go index 0b8031c3f4a..e866fefb2ba 100644 --- a/pkg/e2e/providers_test.go +++ b/pkg/e2e/providers_test.go @@ -93,3 +93,27 @@ func TestProviderRawSetEnvOverridesInheritedEnvMapForm(t *testing.T) { OutputContains("test-1 | CLOUD_REGION=us-east-1"), OutputContains("overrides environment variable")) } + +// https://github.com/docker/compose/issues/14163 +// setsecret must let a provider hand a dependent service a file-based +// secret, service-prefixed, without the secret being declared in the +// compose file - mirroring setenv but delivered as a mounted file instead +// of an environment variable. +func TestProviderSetSecret(t *testing.T) { + providerScenario(t, "setsecret must inject a service-prefixed, file-based secret into a dependent service"). + Step("the dependent service can read the provider's secret content", + ComposeCmd("up"), + StdoutContains("hunter2")) +} + +// https://github.com/docker/compose/issues/14163 +// rawsetsecret must override a secret the user already declared, exactly as +// rawsetenv already does for environment variables, with a visible warning. +func TestProviderRawSetSecretOverridesUserSecret(t *testing.T) { + providerScenario(t, "rawsetsecret must override a user-declared secret's content, with a visible warning"). + Step("the provider's secret content wins and the override is surfaced", + ComposeCmd("up"), + StdoutContains("raw-secret-value"), + OutputNotContains("user-defined-value"), + OutputContains("overrides secret")) +} diff --git a/pkg/e2e/testdata/TestProviderRawSetSecretOverridesUserSecret/compose.yaml b/pkg/e2e/testdata/TestProviderRawSetSecretOverridesUserSecret/compose.yaml new file mode 100644 index 00000000000..12bc8fa5ef1 --- /dev/null +++ b/pkg/e2e/testdata/TestProviderRawSetSecretOverridesUserSecret/compose.yaml @@ -0,0 +1,19 @@ +services: + test: + image: alpine + command: sh -c "cat /run/secrets/shared_api_key" + depends_on: + - secrets + secrets: + - shared_api_key + secrets: + provider: + type: example-provider + options: + name: secrets + type: test1 + size: 1 + +secrets: + shared_api_key: + content: "user-defined-value" diff --git a/pkg/e2e/testdata/TestProviderSetSecret/compose.yaml b/pkg/e2e/testdata/TestProviderSetSecret/compose.yaml new file mode 100644 index 00000000000..833868afc21 --- /dev/null +++ b/pkg/e2e/testdata/TestProviderSetSecret/compose.yaml @@ -0,0 +1,13 @@ +services: + test: + image: alpine + command: sh -c "cat /run/secrets/secrets_db_password" + depends_on: + - secrets + secrets: + provider: + type: example-provider + options: + name: secrets + type: test1 + size: 1