From e208aa1f97de02f3dcdaec409c0acf6ae66f8572 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Wed, 5 Aug 2026 10:34:55 +0200 Subject: [PATCH] fix(config): resolve service environment when computing --hash Containers are created from a project whose service environment is resolved (env_file merged into environment), but since v2.22.0 `config --hash` skipped that resolution, so hashes diverged from the com.docker.compose.config-hash label for services using env_file. Resolve the environment of the hashed services only, so a broken env_file or platforms on an unrelated service still doesn't prevent hashing, and honor --no-env-resolution as an escape hatch. Fixes #14001 Signed-off-by: Guillaume Lours --- cmd/compose/config.go | 22 +++++++++++------ pkg/e2e/config_test.go | 29 +++++++++++++++++++++++ pkg/e2e/env_file_test.go | 14 +++++++++++ pkg/e2e/fixtures/config_hash/app.env | 1 + pkg/e2e/fixtures/config_hash/compose.yaml | 17 +++++++++++++ 5 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 pkg/e2e/fixtures/config_hash/app.env create mode 100644 pkg/e2e/fixtures/config_hash/compose.yaml diff --git a/cmd/compose/config.go b/cmd/compose/config.go index 1677bc4828..331a0b0ea4 100644 --- a/cmd/compose/config.go +++ b/cmd/compose/config.go @@ -23,7 +23,6 @@ import ( "fmt" "io" "os" - "slices" "sort" "strings" @@ -570,18 +569,27 @@ func runHash(ctx context.Context, dockerCli command.Cli, opts configOptions) err return err } - if err := applyPlatforms(project, true); err != nil { + // narrow the project to the services being hashed, so a broken env_file + // or platforms on an unrelated service doesn't prevent hashing the requested ones + project, err = project.WithSelectedServices(services, types.IgnoreDependencies) + if err != nil { return err } - if len(services) == 0 { - services = project.ServiceNames() + if err := applyPlatforms(project, true); err != nil { + return err } - sorted := services - slices.Sort(sorted) + if !opts.noResolveEnv { + // containers are created from a project with service environment + // resolved (env_file merged into environment), so hash the same content + project, err = project.WithServicesEnvironmentResolved(true) + if err != nil { + return err + } + } - for _, name := range sorted { + for _, name := range project.ServiceNames() { s, err := project.GetService(name) if err != nil { return err diff --git a/pkg/e2e/config_test.go b/pkg/e2e/config_test.go index b8bdb5f022..76e8ad0110 100644 --- a/pkg/e2e/config_test.go +++ b/pkg/e2e/config_test.go @@ -17,8 +17,11 @@ package e2e import ( + "fmt" + "strings" "testing" + "gotest.tools/v3/assert" "gotest.tools/v3/icmd" ) @@ -114,3 +117,29 @@ func TestLocalComposeConfig(t *testing.T) { res.Assert(t, icmd.Expected{Out: `gated`}) }) } + +func TestConfigHashMatchesContainerLabel(t *testing.T) { + c := NewParallelCLI(t) + + const projectName = "compose-e2e-config-hash" + defer c.cleanupWithDown(t, projectName) + + c.RunDockerComposeCmd(t, "-f", "./fixtures/config_hash/compose.yaml", "--project-name", projectName, "up", "-d", "--force-recreate") + + containerHashes := map[string]string{} + for _, service := range []string{"with-env-file", "optional-env", "plain"} { + res := c.RunDockerCmd(t, "inspect", "-f", `{{index .Config.Labels "com.docker.compose.config-hash"}}`, + fmt.Sprintf("%s-%s-1", projectName, service)) + containerHashes[service] = strings.TrimSpace(res.Stdout()) + + res = c.RunDockerComposeCmd(t, "-f", "./fixtures/config_hash/compose.yaml", "--project-name", projectName, "config", "--hash", service) + fields := strings.Fields(res.Stdout()) + assert.Equal(t, len(fields), 2) + assert.Equal(t, fields[1], containerHashes[service], "config --hash %s must match the container config-hash label", service) + } + + res := c.RunDockerComposeCmd(t, "-f", "./fixtures/config_hash/compose.yaml", "--project-name", projectName, "config", "--hash", "*") + expected := fmt.Sprintf("optional-env %s\nplain %s\nwith-env-file %s", + containerHashes["optional-env"], containerHashes["plain"], containerHashes["with-env-file"]) + assert.Equal(t, strings.TrimSpace(res.Stdout()), expected) +} diff --git a/pkg/e2e/env_file_test.go b/pkg/e2e/env_file_test.go index 865d7bfa12..cff4ae1dfe 100644 --- a/pkg/e2e/env_file_test.go +++ b/pkg/e2e/env_file_test.go @@ -50,6 +50,20 @@ func TestUnusedMissingEnvFile(t *testing.T) { res := c.RunDockerComposeCmdNoCheck(t, "-f", "./fixtures/env_file/compose.yaml", "scale", "serviceB=1") res.Assert(t, icmd.Expected{ExitCode: 1, Err: "env file /doesnotexist/.env not found"}) + // config --hash should work for services not referencing the missing env file + c.RunDockerComposeCmd(t, "-f", "./fixtures/env_file/compose.yaml", "config", "--hash", "serviceA") + + // but hashing the service with the missing env file must fail, as up would + res = c.RunDockerComposeCmdNoCheck(t, "-f", "./fixtures/env_file/compose.yaml", "config", "--hash", "serviceB") + res.Assert(t, icmd.Expected{ExitCode: 1, Err: "env file /doesnotexist/.env not found"}) + + // unless env_file resolution is explicitly disabled + c.RunDockerComposeCmd(t, "-f", "./fixtures/env_file/compose.yaml", "config", "--no-env-resolution", "--hash", "serviceB") + + // and so must the wildcard, as it includes serviceB + res = c.RunDockerComposeCmdNoCheck(t, "-f", "./fixtures/env_file/compose.yaml", "config", "--hash", "*") + res.Assert(t, icmd.Expected{ExitCode: 1, Err: "env file /doesnotexist/.env not found"}) + // shell completion should list services even with missing env file. // ComposeStandalonePath fails the test outside standalone mode, so only the // plugin form can be the default here. diff --git a/pkg/e2e/fixtures/config_hash/app.env b/pkg/e2e/fixtures/config_hash/app.env new file mode 100644 index 0000000000..01c1ad951b --- /dev/null +++ b/pkg/e2e/fixtures/config_hash/app.env @@ -0,0 +1 @@ +EXAMPLE=value diff --git a/pkg/e2e/fixtures/config_hash/compose.yaml b/pkg/e2e/fixtures/config_hash/compose.yaml new file mode 100644 index 0000000000..009c3af244 --- /dev/null +++ b/pkg/e2e/fixtures/config_hash/compose.yaml @@ -0,0 +1,17 @@ +services: + with-env-file: + image: alpine + command: ["sleep", "infinity"] + env_file: + - app.env + + optional-env: + image: alpine + command: ["sleep", "infinity"] + env_file: + - path: missing-optional.env + required: false + + plain: + image: alpine + command: ["sleep", "infinity"]