diff --git a/AI_AGENT_DISCLOSURE.md b/AI_AGENT_DISCLOSURE.md new file mode 100644 index 0000000000..ca3cbb415d --- /dev/null +++ b/AI_AGENT_DISCLOSURE.md @@ -0,0 +1,4 @@ +> *"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-05 diff --git a/docs/secrets-and-configs-driver-copy.md b/docs/secrets-and-configs-driver-copy.md new file mode 100644 index 0000000000..0dc7d2e35e --- /dev/null +++ b/docs/secrets-and-configs-driver-copy.md @@ -0,0 +1,48 @@ +# `secrets`/`configs`: `driver: copy` + +## Problem + +`secrets..file` and `configs..file` are, by default, interpreted as a path on the **Docker host**: +Compose creates a read-only bind mount from that host path into the container. This works fine when the +daemon is local, but breaks entirely once `DOCKER_HOST` or a Docker context points at a remote engine — +Compose is a client-side tool and cannot bind-mount a path that only exists on the client's machine into a +container running on a different host. + +```yaml +services: + app: + secrets: + - db_password + +secrets: + db_password: + file: ./db_password.txt # only works if the daemon is local +``` + +Against a remote `DOCKER_HOST`, the above fails (or silently mounts the wrong file, if a path with the same +name happens to exist on the remote host). + +## `driver: copy` + +Setting `driver: copy` on a secret or config opts it into being read from the **client's** local filesystem and +copied into the container, instead of bind-mounted from the Docker host: + +```yaml +secrets: + db_password: + file: ./db_password.txt + driver: copy +``` + +This is an explicit **opt-in**, not the default, and this is deliberate: existing users may rely on the current +bind-mount behavior, e.g. to edit the file on the Docker host directly and see the change reflected in the +running container without a restart, or in combination with `docker compose watch`. Switching the default +would silently break those workflows, so nothing changes unless `driver: copy` is set. See +[docker/compose#11867](https://github.com/docker/compose/issues/11867) for the history of that decision. + +Because the content is copied once, at container creation, editing the client's file afterwards has no effect +on already-running containers — recreate the service (`docker compose up`) to pick up a changed file, exactly +as for a secret declared with `content:`. + +`driver: copy` requires `file` to be set. No other `driver` value is supported (as before, any other value is +rejected). diff --git a/pkg/compose/create.go b/pkg/compose/create.go index b30ce1a65c..7d09a2573d 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -1218,14 +1218,17 @@ func buildContainerConfigMounts(p types.Project, s types.ServiceConfig) ([]mount return nil, fmt.Errorf("unsupported external config %s", definedConfig.Name) } - if definedConfig.Driver != "" { + if definedConfig.Driver != "" && definedConfig.Driver != copyDriver { return nil, errors.New("Docker Compose does not support configs.*.driver") //nolint:staticcheck } if definedConfig.TemplateDriver != "" { return nil, errors.New("Docker Compose does not support configs.*.template_driver") //nolint:staticcheck } + if definedConfig.Driver == copyDriver && definedConfig.File == "" { + return nil, fmt.Errorf("config %s: driver: copy requires file to be set", definedConfig.Name) + } - if definedConfig.Environment != "" || definedConfig.Content != "" { + if definedConfig.Environment != "" || definedConfig.Content != "" || definedConfig.Driver == copyDriver { continue } @@ -1268,14 +1271,17 @@ func buildContainerSecretMounts(p types.Project, s types.ServiceConfig) ([]mount return nil, fmt.Errorf("unsupported external secret %s", definedSecret.Name) } - if definedSecret.Driver != "" { + if definedSecret.Driver != "" && definedSecret.Driver != copyDriver { return nil, errors.New("Docker Compose does not support secrets.*.driver") //nolint:staticcheck } if definedSecret.TemplateDriver != "" { return nil, errors.New("Docker Compose does not support secrets.*.template_driver") //nolint:staticcheck } + if definedSecret.Driver == copyDriver && definedSecret.File == "" { + return nil, fmt.Errorf("secret %s: driver: copy requires file to be set", definedSecret.Name) + } - if definedSecret.Environment != "" { + if definedSecret.Environment != "" || definedSecret.Driver == copyDriver { continue } diff --git a/pkg/compose/create_test.go b/pkg/compose/create_test.go index e08e4227da..12675d34d7 100644 --- a/pkg/compose/create_test.go +++ b/pkg/compose/create_test.go @@ -85,6 +85,68 @@ func TestBuildVolumeMount(t *testing.T) { assert.Equal(t, mount.Type, mountTypes.TypeVolume) } +// https://github.com/docker/compose/issues/11867 +// driver: copy must skip the bind mount entirely: the secret/config is +// instead delivered by injectSecrets/injectConfigs (secrets.go), which +// copies the client-read content into the container after it's created. +func TestBuildContainerSecretMounts_CopyDriverSkipsBindMount(t *testing.T) { + project := composetypes.Project{ + Secrets: composetypes.Secrets(map[string]composetypes.SecretConfig{ + "db_password": {Name: "db_password", Driver: copyDriver, File: "/home/user/secret.txt"}, + }), + } + service := composetypes.ServiceConfig{ + Secrets: []composetypes.ServiceSecretConfig{{Source: "db_password"}}, + } + + mounts, err := buildContainerSecretMounts(project, service) + assert.NilError(t, err) + assert.Equal(t, len(mounts), 0) +} + +func TestBuildContainerSecretMounts_CopyDriverRequiresFile(t *testing.T) { + project := composetypes.Project{ + Secrets: composetypes.Secrets(map[string]composetypes.SecretConfig{ + "db_password": {Name: "db_password", Driver: copyDriver}, + }), + } + service := composetypes.ServiceConfig{ + Secrets: []composetypes.ServiceSecretConfig{{Source: "db_password"}}, + } + + _, err := buildContainerSecretMounts(project, service) + assert.ErrorContains(t, err, "driver: copy requires file to be set") +} + +func TestBuildContainerSecretMounts_UnsupportedDriverStillRejected(t *testing.T) { + project := composetypes.Project{ + Secrets: composetypes.Secrets(map[string]composetypes.SecretConfig{ + "db_password": {Name: "db_password", Driver: "vault"}, + }), + } + service := composetypes.ServiceConfig{ + Secrets: []composetypes.ServiceSecretConfig{{Source: "db_password"}}, + } + + _, err := buildContainerSecretMounts(project, service) + assert.ErrorContains(t, err, "does not support secrets.*.driver") +} + +func TestBuildContainerConfigMounts_CopyDriverSkipsBindMount(t *testing.T) { + project := composetypes.Project{ + Configs: composetypes.Configs(map[string]composetypes.ConfigObjConfig{ + "app_config": {Name: "app_config", Driver: copyDriver, File: "/home/user/config.yaml"}, + }), + } + service := composetypes.ServiceConfig{ + Configs: []composetypes.ServiceConfigObjConfig{{Source: "app_config"}}, + } + + mounts, err := buildContainerConfigMounts(project, service) + assert.NilError(t, err) + assert.Equal(t, len(mounts), 0) +} + func TestServiceImageName(t *testing.T) { assert.Equal(t, api.GetImageNameOrDefault(composetypes.ServiceConfig{Image: "myImage"}, "myProject"), "myImage") assert.Equal(t, api.GetImageNameOrDefault(composetypes.ServiceConfig{Name: "aService"}, "myProject"), "myProject-aService") diff --git a/pkg/compose/secrets.go b/pkg/compose/secrets.go index 39051b62ae..12f4d76f41 100644 --- a/pkg/compose/secrets.go +++ b/pkg/compose/secrets.go @@ -21,6 +21,7 @@ import ( "bytes" "context" "fmt" + "os" "strconv" "time" @@ -33,6 +34,22 @@ type mountType string const ( secretMount mountType = "secret" configMount mountType = "config" + + // copyDriver is a Compose-specific `driver` value (secrets.*.driver / + // configs.*.driver) opting a `file`-based secret or config into being + // read from the CLIENT's local filesystem and copied into the + // container, instead of bind-mounted from a path on the Docker host. + // This is the only supported value; any other non-empty driver is + // still rejected, as before. + // + // It exists because `file:` bind-mounting a path off the Docker host + // breaks entirely when the daemon is remote (DOCKER_HOST/context): + // Compose is a client-side tool and cannot bind-mount a path that only + // exists on the client. Switching unconditionally was rejected upstream + // (https://github.com/docker/compose/issues/11867) as a breaking + // change for users who rely on the existing bind mount (live editing on + // the host, `watch`-driven sync); `driver: copy` makes it opt-in. + copyDriver = "copy" ) func (s *composeService) injectSecrets(ctx context.Context, project *types.Project, service types.ServiceConfig, id string) error { @@ -115,6 +132,13 @@ func (s *composeService) resolveFileContent(project *types.Project, source types } return env, nil } + if source.Driver == copyDriver { + content, err := os.ReadFile(source.File) + if err != nil { + return "", fmt.Errorf("reading %s %q from client to copy into container: %w", mountType, source.Name, err) + } + return string(content), nil + } return "", nil } diff --git a/pkg/compose/secrets_test.go b/pkg/compose/secrets_test.go new file mode 100644 index 0000000000..8f9eb11f66 --- /dev/null +++ b/pkg/compose/secrets_test.go @@ -0,0 +1,80 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package compose + +import ( + "os" + "path/filepath" + "testing" + + "github.com/compose-spec/compose-go/v2/types" + "gotest.tools/v3/assert" +) + +// https://github.com/docker/compose/issues/11867 +// driver: copy must read a `file`-based secret or config from the CLIENT's +// local filesystem, so it can be delivered to a container on a remote +// Docker host that has no access to that path. +func TestResolveFileContent_CopyDriver(t *testing.T) { + s := &composeService{} + path := filepath.Join(t.TempDir(), "secret.txt") + assert.NilError(t, os.WriteFile(path, []byte("hunter2"), 0o600)) + + content, err := s.resolveFileContent(&types.Project{}, types.FileObjectConfig{ + Name: "db_password", + Driver: copyDriver, + File: path, + }, secretMount) + assert.NilError(t, err) + assert.Equal(t, content, "hunter2") +} + +func TestResolveFileContent_CopyDriverMissingFile(t *testing.T) { + s := &composeService{} + _, err := s.resolveFileContent(&types.Project{}, types.FileObjectConfig{ + Name: "db_password", + Driver: copyDriver, + File: filepath.Join(t.TempDir(), "missing.txt"), + }, secretMount) + assert.ErrorContains(t, err, "reading secret") +} + +// Content and Environment still take priority over driver: copy, matching +// resolveFileContent's existing precedence. +func TestResolveFileContent_ContentTakesPriorityOverCopyDriver(t *testing.T) { + s := &composeService{} + content, err := s.resolveFileContent(&types.Project{}, types.FileObjectConfig{ + Name: "db_password", + Content: "inline-value", + Driver: copyDriver, + File: filepath.Join(t.TempDir(), "missing.txt"), + }, secretMount) + assert.NilError(t, err) + assert.Equal(t, content, "inline-value") +} + +// Without driver: copy, a File-only source resolves to no content: it stays +// exclusively the bind-mount path's responsibility (buildContainerSecretMounts). +func TestResolveFileContent_PlainFileIsNotCopied(t *testing.T) { + s := &composeService{} + content, err := s.resolveFileContent(&types.Project{}, types.FileObjectConfig{ + Name: "db_password", + File: filepath.Join(t.TempDir(), "secret.txt"), + }, secretMount) + assert.NilError(t, err) + assert.Equal(t, content, "") +} diff --git a/pkg/e2e/secrets_test.go b/pkg/e2e/secrets_test.go index 78ead037d7..2b48d08c72 100644 --- a/pkg/e2e/secrets_test.go +++ b/pkg/e2e/secrets_test.go @@ -19,6 +19,8 @@ package e2e import ( + "os" + "path/filepath" "testing" ) @@ -39,3 +41,30 @@ func TestSecretFromInclude(t *testing.T) { ComposeCmd("run", "included"), OutputContains("this-is-secret")) } + +// https://github.com/docker/compose/issues/11867 +// secrets.*.driver: copy must deliver a one-time snapshot of the CLIENT's +// local file, read and copied into the container — not a live bind mount +// from a path on the Docker host, which breaks entirely once the daemon is +// remote. Editing the client's file after `up` must not reach the running +// container: that's the behavioral difference from the default bind mount, +// and the whole point of opting in. +func TestSecretCopyDriver(t *testing.T) { + s := NewScenario(t, "secrets.*.driver: copy must copy the client's file once, not bind-mount it live") + s.Step("up copies the secret's original content into the container", + ComposeCmd("up", "-d"), + ServiceState("test", "running")). + Step("the container holds the original content", + ComposeCmd("exec", "test", "cat", "/run/secrets/db_password"), + StdoutContains("original-secret")) + + err := os.WriteFile(filepath.Join(s.Dir(), "secret.txt"), []byte("changed-secret"), 0o644) + if err != nil { + t.Fatalf("updating client secret file: %v", err) + } + + s.Step("the running container's copy is unaffected by the client-side edit", + ComposeCmd("exec", "test", "cat", "/run/secrets/db_password"), + StdoutContains("original-secret"), + OutputNotContains("changed-secret")) +} diff --git a/pkg/e2e/testdata/TestSecretCopyDriver/compose.yaml b/pkg/e2e/testdata/TestSecretCopyDriver/compose.yaml new file mode 100644 index 0000000000..7c5cc1586d --- /dev/null +++ b/pkg/e2e/testdata/TestSecretCopyDriver/compose.yaml @@ -0,0 +1,11 @@ +services: + test: + image: alpine + command: sleep infinity + secrets: + - db_password + +secrets: + db_password: + file: ./secret.txt + driver: copy diff --git a/pkg/e2e/testdata/TestSecretCopyDriver/secret.txt b/pkg/e2e/testdata/TestSecretCopyDriver/secret.txt new file mode 100644 index 0000000000..ce3d7c7460 --- /dev/null +++ b/pkg/e2e/testdata/TestSecretCopyDriver/secret.txt @@ -0,0 +1 @@ +original-secret \ No newline at end of file