Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AI_AGENT_DISCLOSURE.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions docs/examples/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
28 changes: 28 additions & 0 deletions docs/extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>` 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
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion pkg/compose/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
91 changes: 74 additions & 17 deletions pkg/compose/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down
38 changes: 38 additions & 0 deletions pkg/compose/plugins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ package compose

import (
"encoding/json"
"os/exec"
"testing"

"github.com/compose-spec/compose-go/v2/types"
"gotest.tools/v3/assert"
)

Expand Down Expand Up @@ -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")
}
24 changes: 24 additions & 0 deletions pkg/e2e/providers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
Original file line number Diff line number Diff line change
@@ -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"
13 changes: 13 additions & 0 deletions pkg/e2e/testdata/TestProviderSetSecret/compose.yaml
Original file line number Diff line number Diff line change
@@ -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