Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions loader/merge_reset_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
38 changes: 38 additions & 0 deletions loader/reset.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package loader

import (
"fmt"
"slices"
"strconv"
"strings"

Expand Down Expand Up @@ -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
Expand All @@ -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]

Expand Down