diff --git a/loader/merge_reset_test.go b/loader/merge_reset_test.go index f721b5f8..69add980 100644 --- a/loader/merge_reset_test.go +++ b/loader/merge_reset_test.go @@ -62,6 +62,62 @@ func Test_LoadWithReset(t *testing.T) { }) } +// Test_LoadWithResetOnKeysNormalizedToSequence is the regression test for +// docker/compose#11816: when two files contribute the same mapping field +// (environment, labels, …), merging normalizes it into a "KEY=VALUE" sequence, +// and a later `!reset` on one of its keys used to be silently ignored. +func Test_LoadWithResetOnKeysNormalizedToSequence(t *testing.T) { + p, err := LoadWithContext(context.TODO(), types.ConfigDetails{ + ConfigFiles: []types.ConfigFile{ + { + Filename: "base.yml", + Content: []byte(` + name: test + services: + foo: + image: foo + environment: + FOO: BAR + KEEP: ME + labels: + com.example.foo: bar +`), + }, + { + Filename: "extra.yml", + Content: []byte(` + services: + foo: + environment: + - ANOTHER=BAR + - PASSTHROUGH + labels: + com.example.extra: bar +`), + }, + { + Filename: "override.yml", + Content: []byte(` + services: + foo: + environment: + FOO: !reset + ANOTHER: !reset + PASSTHROUGH: !reset + labels: + com.example.foo: !reset +`), + }, + }, + }, func(options *Options) { + options.SkipNormalization = true + }) + assert.NilError(t, err) + me := "ME" + assert.DeepEqual(t, p.Services["foo"].Environment, types.MappingWithEquals{"KEEP": &me}) + assert.DeepEqual(t, p.Services["foo"].Labels, types.Labels{"com.example.extra": "bar"}) +} + func Test_DuplicateReset(t *testing.T) { _, err := LoadWithContext(context.TODO(), types.ConfigDetails{ ConfigFiles: []types.ConfigFile{ diff --git a/loader/reset.go b/loader/reset.go index 78836b5c..fe1dda79 100644 --- a/loader/reset.go +++ b/loader/reset.go @@ -18,6 +18,7 @@ package loader import ( "fmt" + "slices" "strconv" "strings" @@ -257,6 +258,10 @@ func (p *ResetProcessor) applyNullOverrides(target any, path tree.Path) error { continue KEYS } } + if seq, ok := e.([]any); ok { + e = p.removeResetKeys(seq, next) + v[k] = e + } err := p.applyNullOverrides(e, next) if err != nil { return err @@ -281,6 +286,39 @@ func (p *ResetProcessor) applyNullOverrides(target any, path tree.Path) error { return nil } +// removeResetKeys drops from a sequence the entries whose key part is targeted +// by a recorded `!reset` path. A `!reset` on a mapping key (e.g. +// `environment.FOO`) records a key path, but once several files contributed +// the field, merging has normalized it into a "KEY=VALUE" sequence (see +// override.mergeToSequence: environment, labels, build.args, …), so the +// mapping branch of applyNullOverrides can no longer reach the key +// (docker/compose#11816). Match such patterns against the key part of each +// entry — "KEY=VALUE" or bare "KEY" — and remove it. +func (p *ResetProcessor) removeResetKeys(seq []any, path tree.Path) []any { + var keys []string + for _, pattern := range p.paths { + last := pattern.Last() + if last == tree.PathMatchAll || !path.Matches(pattern.Parent()) { + continue + } + // path segments escape "." (see tree.Path.Next); entries hold raw keys + keys = append(keys, tree.Path(last).String()) + } + if len(keys) == 0 { + return seq + } + filtered := make([]any, 0, len(seq)) + for _, e := range seq { + if s, ok := e.(string); ok && slices.ContainsFunc(keys, func(key string) bool { + return s == key || strings.HasPrefix(s, key+"=") + }) { + continue + } + filtered = append(filtered, e) + } + return filtered +} + func (p *ResetProcessor) checkForCycle(node *yaml.Node, path tree.Path) error { paths := p.visitedNodes[node]