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
3 changes: 3 additions & 0 deletions AI_AGENT_DISCLOSURE.md
Original file line number Diff line number Diff line change
@@ -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
16 changes: 14 additions & 2 deletions docs/examples/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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",
Expand Down Expand Up @@ -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) {
Expand Down
13 changes: 8 additions & 5 deletions pkg/compose/executor_ops.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand All @@ -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
}
Expand Down
74 changes: 68 additions & 6 deletions pkg/compose/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 (
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
63 changes: 63 additions & 0 deletions pkg/compose/plugins_control_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

11 changes: 7 additions & 4 deletions pkg/e2e/framework.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
}
Expand Down
38 changes: 38 additions & 0 deletions pkg/e2e/providers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}