Handle rawsetenv collisions with overwrite and warning

rawsetenv injects provider variables without the service-name prefix, so
a key can collide with a value already set on the dependent service,
whether declared by the user in environment or emitted by another
provider. Log a warning and overwrite on collision, document the
precedence and the non-deterministic ordering between concurrent
providers, and cover the user-environment override with an e2e test.

Signed-off-by: Yohta Kimura <38206553+rajyan@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yohta Kimura 2026-06-17 11:48:49 +09:00 committed by Guillaume Lours
parent 332e0add14
commit 5c9d611b5f
4 changed files with 55 additions and 12 deletions

View file

@ -106,9 +106,14 @@ When the provider command sends a `rawsetenv` JSON message, Compose injects the
```
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 multiple providers emit the same `rawsetenv` key, the last one
to run will overwrite previous values.
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.

View file

@ -76,7 +76,7 @@ func (s *composeService) runPlugin(ctx context.Context, project *types.Project,
return nil
}
vars, err := s.executePlugin(cmd, command, service)
variables, err := s.executePlugin(cmd, command, service)
if err != nil {
return err
}
@ -90,10 +90,13 @@ 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 vars.prefixed {
for key, val := range variables.prefixed {
s.Environment[prefix+key] = &val
}
for key, val := range vars.raw {
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
@ -131,7 +134,7 @@ func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service ty
decoder := json.NewDecoder(stdout)
defer func() { _ = stdout.Close() }()
vars := pluginVariables{
variables := pluginVariables{
prefixed: types.Mapping{},
raw: types.Mapping{},
}
@ -156,13 +159,13 @@ func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service ty
if !found {
return pluginVariables{}, fmt.Errorf("invalid response from plugin: %s", msg.Message)
}
vars.prefixed[key] = val
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)
}
vars.raw[key] = val
variables.raw[key] = val
case DebugType:
logrus.Debugf("%s: %s", service.Name, msg.Message)
default:
@ -183,7 +186,7 @@ func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service ty
case "stop":
s.events.On(stoppedEvent(service.Name))
}
return vars, nil
return variables, nil
}
func (s *composeService) getPluginBinaryPath(provider string) (path string, err error) {

View file

@ -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

View file

@ -76,7 +76,6 @@ func TestDependsOnMultipleProviders(t *testing.T) {
env := getEnv(res.Combined())
assert.Check(t, slices.Contains(env, "PROVIDER1_URL=https://magic.cloud/provider1"), env)
assert.Check(t, slices.Contains(env, "PROVIDER2_URL=https://magic.cloud/provider2"), env)
assert.Check(t, slices.Contains(env, "CLOUD_REGION=us-east-1"), env)
}
func TestProviderRawSetEnv(t *testing.T) {
@ -92,13 +91,34 @@ func TestProviderRawSetEnv(t *testing.T) {
res := c.RunDockerComposeCmd(t, "-f", "fixtures/providers/rawsetenv.yaml", "--project-name", projectName, "up")
res.Assert(t, icmd.Success)
env := getEnv(res.Combined(), false)
env := getEnv(res.Combined())
// 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())
// 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) []string {
var env []string
scanner := bufio.NewScanner(strings.NewReader(out))