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 <[email protected]>
This commit is contained in:
Guillaume Lours
2026-08-11 08:37:13 +02:00
committed by Nicolas De loof
parent 5573bd2601
commit 7bc2630b65
5 changed files with 76 additions and 7 deletions
+15 -7
View File
@@ -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
}
// 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 err := applyPlatforms(project, true); err != nil {
return err
}
if len(services) == 0 {
services = project.ServiceNames()
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
}
}
sorted := services
slices.Sort(sorted)
for _, name := range sorted {
for _, name := range project.ServiceNames() {
s, err := project.GetService(name)
if err != nil {
return err
+29
View File
@@ -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)
}
+14
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
EXAMPLE=value
+17
View File
@@ -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"]