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
1 change: 1 addition & 0 deletions docs/examples/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ func up(options options, args []string) {
fmt.Printf(`{ "type": "info", "message": "Processing ... %d%%" }%s`, i*100/options.size, lineSeparator)
}
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)
}

func down(_ *cobra.Command, _ []string) {
Expand Down
18 changes: 17 additions & 1 deletion docs/extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ JSON messages MUST include a `type` and a `message` attribute.
`type` can be either:
- `info`: Reports status updates to the user. Compose will render message as the service state in the progress UI
- `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. See next section for further details.
- `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.
- `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 Down Expand Up @@ -99,6 +100,21 @@ automatically prefixing it with the service name. For example, if `awesomecloud
Then the `app` service, which depends on the service managed by the provider, will receive a `DATABASE_URL` environment variable injected
into its runtime environment.

When the provider command sends a `rawsetenv` JSON message, Compose injects the variable as-is without any prefix:
```json
{"type": "rawsetenv", "message": "SECRET_KEY=xxx"}
```
The `app` service will receive `SECRET_KEY` exactly as specified, regardless of the provider service name.
This is useful when injecting secrets or configuration values that must match exact variable names expected by
applications or frameworks.

Unlike `setenv`, which avoids collisions through automatic prefixing, `rawsetenv` keys are the provider's
responsibility to keep unique. If a `rawsetenv` key collides with a variable already set on the dependent service,
the existing value is overwritten and Compose logs a warning. This includes variables declared by the user in the
service `environment` section as well as values emitted by other providers. Providers that are not linked by a
`depends_on` relationship may run concurrently, so when several of them emit the same `rawsetenv` key the resulting
value is not deterministic.

> __Note:__ The `compose up` provider command _MUST_ be idempotent. If resource is already running, the command _MUST_ set
> the same environment variables to ensure consistent configuration of dependent services.

Expand Down
45 changes: 33 additions & 12 deletions pkg/compose/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,16 @@ const (
ErrorType = "error"
InfoType = "info"
SetEnvType = "setenv"
RawSetEnvType = "rawsetenv"
DebugType = "debug"
providerMetadataDirectory = "compose/providers"
)

type pluginVariables struct {
prefixed types.Mapping
raw types.Mapping
}

var mux sync.Mutex

func (s *composeService) runPlugin(ctx context.Context, project *types.Project, service types.ServiceConfig, command string) error {
Expand Down Expand Up @@ -84,16 +90,22 @@ func (s *composeService) runPlugin(ctx context.Context, project *types.Project,
for name, s := range project.Services {
if _, ok := s.DependsOn[service.Name]; ok {
prefix := strings.ToUpper(service.Name) + "_"
for key, val := range variables {
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rawsetenv silently overwrites user-defined env vars

If a user sets environment: { CLOUD_REGION: eu-west-1 } and a provider emits rawsetenv CLOUD_REGION=us-east-1, the user value is silently clobbered. setenv was immune thanks to the prefix.
Please either skip writes when the key already exists, or we need to document this precedence explicitly.

@ndeloof what is you preference here? I'm in favor of the overwrite + warning message in logs, and you?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with overwrite + warning as suggested. One clarification on the framing though: setenv normally doesn't overwrite anything, since the prefix gives each provider its own namespace and collisions are rare. But the write has no existence check either, so if a service does define the exact prefixed key (e.g. a user-set SECRETS_URL and a provider named "secret" set URL), setenv silently overwrites it the same way. I kept the warning on rawsetenv only since prefixed collisions are unlikely, but can extend it to setenv if you'd prefer consistency.

}
project.Services[name] = s
}
}
return nil
}

func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service types.ServiceConfig) (types.Mapping, error) { //nolint:gocyclo
func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service types.ServiceConfig) (pluginVariables, error) { //nolint:gocyclo
var action string
switch command {
case "up":
Expand All @@ -106,23 +118,26 @@ func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service ty
s.events.On(stoppingEvent(service.Name))
action = "stop"
default:
return nil, fmt.Errorf("unsupported plugin command: %s", command)
return pluginVariables{}, fmt.Errorf("unsupported plugin command: %s", command)
}

stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
return pluginVariables{}, err
}

err = cmd.Start()
if err != nil {
return nil, err
return pluginVariables{}, err
}

decoder := json.NewDecoder(stdout)
defer func() { _ = stdout.Close() }()

variables := types.Mapping{}
variables := pluginVariables{
prefixed: types.Mapping{},
raw: types.Mapping{},
}

for {
var msg JsonMessage
Expand All @@ -131,31 +146,37 @@ func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service ty
break
}
if err != nil {
return nil, err
return pluginVariables{}, err
}
switch msg.Type {
case ErrorType:
s.events.On(newEvent(service.Name, api.Error, firstLine(msg.Message)))
return nil, errors.New(msg.Message)
return pluginVariables{}, errors.New(msg.Message)
case InfoType:
s.events.On(newEvent(service.Name, api.Working, firstLine(msg.Message)))
case SetEnvType:
key, val, found := strings.Cut(msg.Message, "=")
if !found {
return nil, fmt.Errorf("invalid response from plugin: %s", msg.Message)
return pluginVariables{}, fmt.Errorf("invalid response from plugin: %s", msg.Message)
}
variables.prefixed[key] = val
case RawSetEnvType:
key, val, found := strings.Cut(msg.Message, "=")
if !found {
return pluginVariables{}, fmt.Errorf("invalid response from plugin: %s", msg.Message)
}
variables[key] = val
variables.raw[key] = val
case DebugType:
logrus.Debugf("%s: %s", service.Name, msg.Message)
default:
return nil, fmt.Errorf("invalid response from plugin: %s", msg.Type)
return pluginVariables{}, fmt.Errorf("invalid response from plugin: %s", msg.Type)
}
}

err = cmd.Wait()
if err != nil {
s.events.On(errorEvent(service.Name, err.Error()))
return nil, fmt.Errorf("failed to %s service provider: %s", action, err.Error())
return pluginVariables{}, fmt.Errorf("failed to %s service provider: %s", action, err.Error())
}
switch command {
case "up":
Expand Down
15 changes: 15 additions & 0 deletions pkg/e2e/fixtures/providers/rawsetenv-override.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
services:
test:
image: alpine
command: env
environment:
CLOUD_REGION: user-defined-region
depends_on:
- secrets
secrets:
provider:
type: example-provider
options:
name: secrets
type: test1
size: 1
13 changes: 13 additions & 0 deletions pkg/e2e/fixtures/providers/rawsetenv.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
services:
test:
image: alpine
command: env
depends_on:
- secrets
secrets:
provider:
type: example-provider
options:
name: secrets
type: test1
size: 1
41 changes: 41 additions & 0 deletions pkg/e2e/providers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,47 @@ func TestDependsOnMultipleProviders(t *testing.T) {
assert.Check(t, slices.Contains(env, "PROVIDER2_URL=https://magic.cloud/provider2"), env)
}

func TestProviderRawSetEnv(t *testing.T) {
provider, err := findExecutable("example-provider")
assert.NilError(t, err)

path := fmt.Sprintf("%s%s%s", os.Getenv("PATH"), string(os.PathListSeparator), filepath.Dir(provider))
c := NewParallelCLI(t, WithEnv("PATH="+path))
const projectName = "rawsetenv"
t.Cleanup(func() {
c.cleanupWithDown(t, projectName)
})

res := c.RunDockerComposeCmd(t, "-f", "fixtures/providers/rawsetenv.yaml", "--project-name", projectName, "up")
res.Assert(t, icmd.Success)
env := getEnv(res.Combined(), false)
// setenv: prefixed with service name
assert.Check(t, slices.Contains(env, "SECRETS_URL=https://magic.cloud/secrets"), env)
// rawsetenv: injected as-is without prefix
assert.Check(t, slices.Contains(env, "CLOUD_REGION=us-east-1"), env)
}

func TestProviderRawSetEnvOverridesUserEnv(t *testing.T) {
provider, err := findExecutable("example-provider")
assert.NilError(t, err)

path := fmt.Sprintf("%s%s%s", os.Getenv("PATH"), string(os.PathListSeparator), filepath.Dir(provider))
c := NewParallelCLI(t, WithEnv("PATH="+path))
const projectName = "rawsetenv-override"
t.Cleanup(func() {
c.cleanupWithDown(t, projectName)
})

res := c.RunDockerComposeCmd(t, "-f", "fixtures/providers/rawsetenv-override.yaml", "--project-name", projectName, "up")
res.Assert(t, icmd.Success)
env := getEnv(res.Combined(), false)
// rawsetenv overrides a user-defined environment variable
assert.Check(t, slices.Contains(env, "CLOUD_REGION=us-east-1"), env)
assert.Check(t, !slices.Contains(env, "CLOUD_REGION=user-defined-region"), env)
// the override is surfaced to the user rather than happening silently
assert.Check(t, strings.Contains(res.Combined(), "overrides environment variable"), res.Combined())
}

func getEnv(out string, run bool) []string {
var env []string
scanner := bufio.NewScanner(strings.NewReader(out))
Expand Down