Skip to content

interpolation: sort map keys for deterministic error reporting - #895

Closed
mahesh-sadupalli wants to merge 1 commit into
compose-spec:mainfrom
mahesh-sadupalli:fix/interpolation-deterministic-error-order
Closed

interpolation: sort map keys for deterministic error reporting#895
mahesh-sadupalli wants to merge 1 commit into
compose-spec:mainfrom
mahesh-sadupalli:fix/interpolation-deterministic-error-order

Conversation

@mahesh-sadupalli

Copy link
Copy Markdown

Problem

docker compose config --quiet (and docker compose up) can report a different
missing-required-variable error on each run when multiple config sections reference
absent required variables. For example, one run may report:

error while interpolating services.traefik.environment.[]: required variable TIMEZONE is missing a value

and the next may report:

error while interpolating services.traefik.volumes.[].source: required variable TRAEFIK_ACME_PATH is missing a value

Root cause: recursiveInterpolate and Interpolate iterate over
map[string]interface{} directly. Go randomises map iteration order, so the
first missing variable encountered varies between runs.

Fix

Sort the map keys alphabetically before iterating in both Interpolate and
recursiveInterpolate. This adds a small sortedKeys helper and changes two
range loops to range over a sorted key slice instead.

The output map[string]interface{} is unchanged — only the order in which errors
are encountered becomes deterministic. With alphabetical traversal the error now
always identifies the lexicographically first problematic path, giving users
a stable, reproducible message to act on.

Test

A new TestInterpolateRequiredErrorIsDeterministic test invokes Interpolate
100 times on a config with two map keys ("aaa" and "zzz") both referencing
a missing required variable, and asserts that every run reports the same error
and that the path contains "aaa" (the alphabetically first key).

Fixes docker/compose#13712

Iterating over a Go map yields keys in a random order. When
recursiveInterpolate processes a map-valued config node (e.g. the
per-service config containing "environment", "volumes", …) and a
missing required variable is encountered, the error reported depends
on whichever key happens to be visited first in that run.

Sort the map keys alphabetically before iterating in both Interpolate
and recursiveInterpolate so that the first error reported is always the
same across repeated calls with identical input. This makes `docker
compose config --quiet` and `docker compose up` produce a consistent
error message when required variables are absent.

Add a regression test that invokes Interpolate 100 times with two
map keys both referencing a missing required variable and asserts that
the reported key is always the alphabetically earlier one.

Fixes docker/compose#13712

Signed-off-by: Mahesh Sadupalli <mahesh.sadupalli@gmail.com>

@glours glours left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t think this PR addresses the root cause. Instead of returning errors one at a time, we should return all of them at once. I’ve opened a separate PR that takes this approach
#903

Comment on lines +131 to +139
func sortedKeys(m map[string]interface{}) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helper already exists in the repo: utils.MapKeys (utils/collectionutils.go) does exactly this, generically, and is already used for the same "iterate map keys deterministically" purpose in types/project.go and graph/cycle.go.

Could you drop sortedKeys (and the sort import) and use it instead?


var firstErr string
for range 100 {
_, err := Interpolate(config, Options{})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Options{} makes LookupValue fall back to os.LookupEnv, so the test silently depends on MISSING not being set in the process environment. Passing Options{LookupValue: defaultMapping} like the other tests in this file makes it hermetic.

Comment on lines +238 to +252
for range 100 {
_, err := Interpolate(config, Options{})
if err == nil {
t.Fatal("expected an interpolation error, got nil")
}
if firstErr == "" {
firstErr = err.Error()
} else if err.Error() != firstErr {
t.Fatalf("non-deterministic error: got %q, want %q", err.Error(), firstErr)
}
}
// Alphabetically first key ("aaa") must appear in the path.
if !strings.Contains(firstErr, "service.aaa") {
t.Fatalf("expected error path to contain 'service.aaa', got: %s", firstErr)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use the assert library for this. Checking the exact error message would also make the second assertion unnecessary, since it would already verify that the alphabetical ordering is correct.

Suggested change
for range 100 {
_, err := Interpolate(config, Options{})
if err == nil {
t.Fatal("expected an interpolation error, got nil")
}
if firstErr == "" {
firstErr = err.Error()
} else if err.Error() != firstErr {
t.Fatalf("non-deterministic error: got %q, want %q", err.Error(), firstErr)
}
}
// Alphabetically first key ("aaa") must appear in the path.
if !strings.Contains(firstErr, "service.aaa") {
t.Fatalf("expected error path to contain 'service.aaa', got: %s", firstErr)
}
for range 100 {
_, err := Interpolate(config, Options{LookupValue: defaultMapping})
assert.Error(t, err, "error while interpolating service.aaa: required variable MISSING is missing a value: required")
}

@mahesh-sadupalli

Copy link
Copy Markdown
Author

Thanks for the review and the improved approach in #903 — collecting all errors is clearly the right fix. Closing this in favour of #903.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] docker compose config --quiet produces non-deterministic result for required variables

2 participants