diff --git a/AI_AGENT_DISCLOSURE.md b/AI_AGENT_DISCLOSURE.md new file mode 100644 index 0000000000..08775f8145 --- /dev/null +++ b/AI_AGENT_DISCLOSURE.md @@ -0,0 +1,3 @@ +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-16 diff --git a/docs/examples/provider.go b/docs/examples/provider.go index 83ab901993..a4e592a468 100644 --- a/docs/examples/provider.go +++ b/docs/examples/provider.go @@ -40,8 +40,10 @@ func main() { } type options struct { - db string - size int + db string + size int + mounts []string + secrets []string } func composeCommand() *cobra.Command { @@ -65,6 +67,8 @@ func composeCommand() *cobra.Command { upCmd.Flags().IntVar(&options.size, "size", 10, "Database size in GB") upCmd.Flags().String("name", "", "Name of the database to be created") _ = upCmd.MarkFlagRequired("name") + upCmd.Flags().StringSliceVar(&options.mounts, "mount", nil, "Volume mount to inject (format: source:target)") + upCmd.Flags().StringSliceVar(&options.secrets, "secret", nil, "Secret to inject (format: name=file)") downCmd := &cobra.Command{ Use: "down", @@ -121,6 +125,14 @@ 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) + for _, m := range options.mounts { + mountMsg, _ := json.Marshal(map[string]string{"type": "mount", "message": m}) + fmt.Println(string(mountMsg)) + } + for _, s := range options.secrets { + secretMsg, _ := json.Marshal(map[string]string{"type": "secret", "message": s}) + fmt.Println(string(secretMsg)) + } } func down(_ *cobra.Command, _ []string) { diff --git a/pkg/compose/executor_ops.go b/pkg/compose/executor_ops.go index 13738d045b..70b299953d 100644 --- a/pkg/compose/executor_ops.go +++ b/pkg/compose/executor_ops.go @@ -79,9 +79,12 @@ func (exec *planExecutor) execRemoveVolume(ctx context.Context, op Operation) er func (exec *planExecutor) execCreateContainer(ctx context.Context, node *PlanNode) error { op := node.Operation service := *op.Service + if liveService, ok := exec.project.Services[op.Service.Name]; ok { + service = liveService + } // Detach VolumesFrom from the source slice: resolveServiceReferences mutates // entries in place, and the shallow struct copy still shares the backing array. - service.VolumesFrom = slices.Clone(op.Service.VolumesFrom) + service.VolumesFrom = slices.Clone(service.VolumesFrom) // Resolve service references (network_mode, ipc, pid, volumes_from) to // actual container IDs from the in-memory view, which already includes @@ -96,9 +99,9 @@ func (exec *planExecutor) execCreateContainer(ctx context.Context, node *PlanNod labels := mergeLabels(service.Labels, service.CustomLabels) if op.Inherited != nil { // This is a recreate: add the replace label - replacedName := op.Service.ContainerName + replacedName := service.ContainerName if replacedName == "" { - replacedName = fmt.Sprintf("%s%s%d", op.Service.Name, api.Separator, op.Number) + replacedName = fmt.Sprintf("%s%s%d", service.Name, api.Separator, op.Number) } labels = labels.Add(api.ContainerReplaceLabel, replacedName) } @@ -120,9 +123,9 @@ func (exec *planExecutor) execCreateContainer(ctx context.Context, node *PlanNod }) // Make the new container visible to subsequent execCreateContainer calls - // that resolve service references against op.Service.Name. + // that resolve service references against service.Name. exec.containersMu.Lock() - exec.containersByService[op.Service.Name] = append(exec.containersByService[op.Service.Name], ctr) + exec.containersByService[service.Name] = append(exec.containersByService[service.Name], ctr) exec.containersMu.Unlock() return nil } diff --git a/pkg/compose/plugins.go b/pkg/compose/plugins.go index 9142a7a38c..1e2abf3fac 100644 --- a/pkg/compose/plugins.go +++ b/pkg/compose/plugins.go @@ -29,6 +29,7 @@ import ( "strings" "sync" + "github.com/compose-spec/compose-go/v2/format" "github.com/compose-spec/compose-go/v2/types" "github.com/containerd/errdefs" "github.com/docker/cli/cli-plugins/manager" @@ -40,8 +41,10 @@ import ( ) type JsonMessage struct { - Type string `json:"type"` - Message string `json:"message,omitempty"` + Type string `json:"type"` + Message string `json:"message,omitempty"` + Mount *types.ServiceVolumeConfig `json:"mount,omitempty"` + Secret *types.ServiceSecretConfig `json:"secret,omitempty"` } const ( @@ -50,6 +53,8 @@ const ( SetEnvType = "setenv" RawSetEnvType = "rawsetenv" DebugType = "debug" + MountType = "mount" + SecretType = "secret" providerMetadataDirectory = "compose/providers" // GetServiceConfigType is a message the provider sends to receive, on @@ -59,8 +64,11 @@ const ( ) type pluginVariables struct { - prefixed types.Mapping - raw types.Mapping + prefixed types.Mapping + raw types.Mapping + mounts []types.ServiceVolumeConfig + secrets []types.ServiceSecretConfig + projectSecrets map[string]types.SecretConfig } var mux sync.Mutex @@ -104,6 +112,23 @@ func (s *composeService) runPlugin(ctx context.Context, project *types.Project, } s.Environment[key] = &val } + if len(variables.mounts) > 0 { + s.Volumes = append(s.Volumes, variables.mounts...) + } + if len(variables.secrets) > 0 { + s.Secrets = append(s.Secrets, variables.secrets...) + for _, secret := range variables.secrets { + if projSecret, ok := variables.projectSecrets[secret.Source]; ok { + if project.Secrets == nil { + project.Secrets = make(types.Secrets) + } + if existing, ok := project.Secrets[secret.Source]; ok && (existing.File != projSecret.File || existing.Environment != projSecret.Environment || existing.Name != projSecret.Name) { + logrus.Warnf("provider %q overrides secret %q in project", service.Name, secret.Source) + } + project.Secrets[secret.Source] = projSecret + } + } + } project.Services[name] = s } } @@ -180,8 +205,9 @@ 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{}, + projectSecrets: map[string]types.SecretConfig{}, } for { @@ -226,6 +252,42 @@ func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service ty }() case DebugType: logrus.Debugf("%s: %s", service.Name, msg.Message) + case MountType: + if msg.Mount != nil { + variables.mounts = append(variables.mounts, *msg.Mount) + } else { + volume, err := format.ParseVolume(msg.Message) + if err != nil { + return pluginVariables{}, fmt.Errorf("invalid response from plugin (expected valid volume string): %s", msg.Message) + } + variables.mounts = append(variables.mounts, volume) + } + case SecretType: + if msg.Secret != nil { + variables.secrets = append(variables.secrets, *msg.Secret) + if msg.Secret.Source != "" && msg.Message != "" { + variables.projectSecrets[msg.Secret.Source] = types.SecretConfig{ + Name: msg.Secret.Source, + File: msg.Message, + } + } + } else { + sep := "=" + if !strings.Contains(msg.Message, "=") && strings.Contains(msg.Message, ":") { + sep = ":" + } + key, val, found := strings.Cut(msg.Message, sep) + if !found { + return pluginVariables{}, fmt.Errorf("invalid response from plugin (expected source=file or source:file): %s", msg.Message) + } + variables.secrets = append(variables.secrets, types.ServiceSecretConfig{ + Source: key, + }) + variables.projectSecrets[key] = types.SecretConfig{ + Name: key, + File: val, + } + } default: return pluginVariables{}, fmt.Errorf("invalid response from plugin: %s", msg.Type) } diff --git a/pkg/compose/plugins_control_test.go b/pkg/compose/plugins_control_test.go index 5d1179c852..980a08fd69 100644 --- a/pkg/compose/plugins_control_test.go +++ b/pkg/compose/plugins_control_test.go @@ -94,3 +94,66 @@ func TestHelperProviderConfig(t *testing.T) { } os.Exit(0) } + +func TestExecutePlugin_MountsAndSecrets(t *testing.T) { + mockCtrl := gomock.NewController(t) + cli := mocks.NewMockCli(mockCtrl) + cli.EXPECT().Client().Return(mocks.NewMockAPIClient(mockCtrl)).AnyTimes() + svc, err := NewComposeService(cli, WithEventProcessor(noopEventProcessor{})) + assert.NilError(t, err) + + cmd := exec.Command(os.Args[0], "-test.run=TestHelperProviderMountsAndSecrets") + cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1") + + service := types.ServiceConfig{ + Name: "db", + Provider: &types.ServiceProviderConfig{ + Type: "test-provider", + }, + } + variables, err := svc.(*composeService).executePlugin(cmd, "up", service) + assert.NilError(t, err) + + assert.Equal(t, len(variables.mounts), 2) + assert.Equal(t, variables.mounts[0].Source, "/host/path") + assert.Equal(t, variables.mounts[0].Target, "/container/path") + assert.Equal(t, variables.mounts[1].Source, "my-vol") + assert.Equal(t, variables.mounts[1].Target, "/data") + + assert.Equal(t, len(variables.secrets), 2) + assert.Equal(t, variables.secrets[0].Source, "my_secret") + assert.Equal(t, variables.projectSecrets["my_secret"].File, "/tmp/secret1") + assert.Equal(t, variables.secrets[1].Source, "other_secret") + assert.Equal(t, variables.projectSecrets["other_secret"].File, "/tmp/secret2") +} + +func TestHelperProviderMountsAndSecrets(t *testing.T) { + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + t.Skip("helper process for TestExecutePlugin_MountsAndSecrets") + } + emit := func(msg JsonMessage) { + if err := json.NewEncoder(os.Stdout).Encode(msg); err != nil { + os.Exit(1) + } + } + + // Mount string format + emit(JsonMessage{Type: MountType, Message: "/host/path:/container/path"}) + // Mount object format + emit(JsonMessage{ + Type: MountType, + Mount: &types.ServiceVolumeConfig{ + Type: "volume", + Source: "my-vol", + Target: "/data", + }, + }) + + // Secret string format (source=file) + emit(JsonMessage{Type: SecretType, Message: "my_secret=/tmp/secret1"}) + // Secret string format (source:file) + emit(JsonMessage{Type: SecretType, Message: "other_secret:/tmp/secret2"}) + + os.Exit(0) +} + diff --git a/pkg/e2e/framework.go b/pkg/e2e/framework.go index ba444767f7..7e34d6b4fc 100644 --- a/pkg/e2e/framework.go +++ b/pkg/e2e/framework.go @@ -126,10 +126,13 @@ func copyLocalConfig(t testing.TB, configDir string) { t.Helper() // copy local config.json if exists - localConfig := filepath.Join(os.Getenv("HOME"), ".docker", "config.json") - // if no config present just continue - if _, err := os.Stat(localConfig); err != nil { - // copy the local config.json to the test config dir + home, err := os.UserHomeDir() + if err != nil { + return + } + localConfig := filepath.Join(home, ".docker", "config.json") + // if config present copy to test config dir + if _, err := os.Stat(localConfig); err == nil { CopyFile(t, localConfig, filepath.Join(configDir, "config.json")) } } diff --git a/pkg/e2e/providers_test.go b/pkg/e2e/providers_test.go index c2b5245f3e..783bc64864 100644 --- a/pkg/e2e/providers_test.go +++ b/pkg/e2e/providers_test.go @@ -103,3 +103,41 @@ func TestProviderRawSetEnvOverridesInheritedEnvMapForm(t *testing.T) { OutputContains("test-1 | CLOUD_REGION=us-east-1"), OutputContains("overrides environment variable")) } + +func TestProviderMountsAndSecrets(t *testing.T) { + tmpDir := t.TempDir() + mountDir := filepath.Join(tmpDir, "provider-data") + secretFile := filepath.Join(tmpDir, "provider-secret") + _ = os.MkdirAll(mountDir, 0o755) + _ = os.WriteFile(filepath.Join(mountDir, "hello"), []byte("hello from provider mount"), 0o644) + _ = os.WriteFile(secretFile, []byte("hello from provider secret"), 0o644) + + // We need to create a test folder for the scenario to pick up the compose.yaml + scenarioDir := filepath.Join("testdata", "TestProviderMountsAndSecrets") + _ = os.MkdirAll(scenarioDir, 0o755) + yamlContent := fmt.Sprintf(` +services: + db: + provider: + type: example-provider + options: + type: postgres + name: my_db + size: 10 + mount: %s:/provider-data + secret: my_secret=%s + test: + image: alpine + depends_on: + - db + command: sh -c "cat /provider-data/hello && cat /run/secrets/my_secret" +`, filepath.ToSlash(mountDir), filepath.ToSlash(secretFile)) + _ = os.WriteFile(filepath.Join(scenarioDir, "compose.yaml"), []byte(yamlContent), 0o644) + defer func() { _ = os.RemoveAll(scenarioDir) }() // clean up + + providerScenario(t, "a provider injecting mounts and secrets"). + Step("the service sees both the mount and the secret", + ComposeCmd("up", "--build"), + OutputContains("hello from provider mount"), + OutputContains("hello from provider secret")) +}