diff --git a/AI_AGENT_DISCLOSURE.md b/AI_AGENT_DISCLOSURE.md new file mode 100644 index 0000000000..ca3cbb415d --- /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 8fa5635e12..ce79affb6b 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 268f56cc04..52239403bd 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 b30ce1a65c..b2e04e64a1 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 1514761b47..d73cc55812 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 9ff2f653c4..b0a7fd3d7f 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 0b8031c3f4..e866fefb2b 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 0000000000..12bc8fa5ef --- /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 0000000000..833868afc2 --- /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