From 5c9d611b5fc69da6d711fe6a77c8d71b80bed71a Mon Sep 17 00:00:00 2001 From: Yohta Kimura <38206553+rajyan@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:48:49 +0900 Subject: [PATCH] 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) --- docs/extension.md | 11 ++++++--- pkg/compose/plugins.go | 17 +++++++------ .../providers/rawsetenv-override.yaml | 15 ++++++++++++ pkg/e2e/providers_test.go | 24 +++++++++++++++++-- 4 files changed, 55 insertions(+), 12 deletions(-) create mode 100644 pkg/e2e/fixtures/providers/rawsetenv-override.yaml diff --git a/docs/extension.md b/docs/extension.md index 876f923c6..1234a3b93 100644 --- a/docs/extension.md +++ b/docs/extension.md @@ -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. diff --git a/pkg/compose/plugins.go b/pkg/compose/plugins.go index 29358bd47..504c31f62 100644 --- a/pkg/compose/plugins.go +++ b/pkg/compose/plugins.go @@ -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) { diff --git a/pkg/e2e/fixtures/providers/rawsetenv-override.yaml b/pkg/e2e/fixtures/providers/rawsetenv-override.yaml new file mode 100644 index 000000000..9afc7ec92 --- /dev/null +++ b/pkg/e2e/fixtures/providers/rawsetenv-override.yaml @@ -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 diff --git a/pkg/e2e/providers_test.go b/pkg/e2e/providers_test.go index 203e6e1a4..8b11720ce 100644 --- a/pkg/e2e/providers_test.go +++ b/pkg/e2e/providers_test.go @@ -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))