diff --git a/docs/examples/provider.go b/docs/examples/provider.go index 79fd3256eed..9f83f407a3f 100644 --- a/docs/examples/provider.go +++ b/docs/examples/provider.go @@ -90,6 +90,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) { diff --git a/docs/extension.md b/docs/extension.md index 3682f15655c..ced66880c87 100644 --- a/docs/extension.md +++ b/docs/extension.md @@ -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 @@ -99,6 +100,16 @@ 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 multiple providers emit the same `rawsetenv` key, the last one +to run will overwrite previous values. + > __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 a0eee8a37bb..cbfb8229a4d 100644 --- a/pkg/compose/plugins.go +++ b/pkg/compose/plugins.go @@ -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 { @@ -67,7 +73,7 @@ func (s *composeService) runPlugin(ctx context.Context, project *types.Project, return err } - variables, err := s.executePlugin(cmd, command, service) + vars, err := s.executePlugin(cmd, command, service) if err != nil { return err } @@ -77,16 +83,19 @@ 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 vars.prefixed { s.Environment[prefix+key] = &val } + for key, val := range vars.raw { + s.Environment[key] = &val + } project.Services[name] = s } } return nil } -func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service types.ServiceConfig) (types.Mapping, error) { +func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service types.ServiceConfig) (pluginVariables, error) { var action string switch command { case "up": @@ -96,23 +105,26 @@ func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service ty s.events.On(removingEvent(service.Name)) action = "remove" 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{} + vars := pluginVariables{ + prefixed: types.Mapping{}, + raw: types.Mapping{}, + } for { var msg JsonMessage @@ -121,31 +133,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) + } + vars.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 + vars.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": @@ -153,7 +171,7 @@ func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service ty case "down": s.events.On(removedEvent(service.Name)) } - return variables, nil + return vars, nil } func (s *composeService) getPluginBinaryPath(provider string) (path string, err error) { diff --git a/pkg/e2e/fixtures/providers/rawsetenv.yaml b/pkg/e2e/fixtures/providers/rawsetenv.yaml new file mode 100644 index 00000000000..1dde88cf4c8 --- /dev/null +++ b/pkg/e2e/fixtures/providers/rawsetenv.yaml @@ -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 diff --git a/pkg/e2e/providers_test.go b/pkg/e2e/providers_test.go index b026f1f1434..5cfdf8c628b 100644 --- a/pkg/e2e/providers_test.go +++ b/pkg/e2e/providers_test.go @@ -45,6 +45,27 @@ func TestDependsOnMultipleProviders(t *testing.T) { env := getEnv(res.Combined(), false) 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) { + 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 getEnv(out string, run bool) []string {