Skip to content
Merged
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
4 changes: 2 additions & 2 deletions loader/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -602,8 +602,8 @@ func load(ctx context.Context, configDetails types.ConfigDetails, opts *Options,
// per `include:`d file): unlike validation.Validate, this is a batch scan
// with no per-file/fail-fast need, so it must not ride along on
// loadYamlModel's recursive call site or it fires once per included file.
if check := opts.UnsupportedAttributesCheck; check != nil {
check.Report(detectUnsupportedAttributes(dict, check.Patterns))
if check := opts.UnsupportedAttributesCheck; check != nil && check.Report != nil {
check.Report(detectUnsupportedAttributes(dict, check))
}

if !opts.SkipNormalization {
Expand Down
87 changes: 73 additions & 14 deletions loader/unsupported_attributes.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package loader
import (
"cmp"
"slices"
"strings"

"github.com/compose-spec/compose-go/v2/tree"
)
Expand Down Expand Up @@ -53,35 +54,77 @@ type UnsupportedAttributePattern struct {
Detect func(value any) bool
}

// UnsupportedAttributesCheck bundles a set of UnsupportedAttributePattern
// with the callback invoked once, after loading, with every match found
// (possibly empty).
// UnsupportedAttributesCheck bundles the detection rules with the callback
// invoked once, after loading, with every match found (possibly empty).
//
// Detection combines two complementary rule sets over one walk:
//
// - Patterns is a denylist: attributes the caller knows it does not honor,
// with optional value predicates for cases like `ports[].mode: host`.
// - Supported is an allowlist: when non-empty, every attribute matching
// none of its paths is reported too. It makes the screening fail-closed —
// an attribute the specification gains later is reported until the
// runtime deliberately declares it. schema.AttributePaths returns the
// full specification inventory to build it from (remove what the runtime
// does not implement); extension keys (x-*) are never reported by this
// rule set.
type UnsupportedAttributesCheck struct {
Patterns []UnsupportedAttributePattern
Report func([]UnsupportedAttribute)
Patterns []UnsupportedAttributePattern
Supported []tree.Path
Report func([]UnsupportedAttribute)
}

// WithUnsupportedAttributesCheck registers a set of UnsupportedAttributePattern
// to be evaluated against the loaded model. report is invoked once, after
// loading, with every match found (possibly empty).
// loading, with every match found (possibly empty). Composable with
// WithSupportedAttributes: both feed the same walk and report.
func WithUnsupportedAttributesCheck(patterns []UnsupportedAttributePattern, report func([]UnsupportedAttribute)) func(*Options) {
return func(opts *Options) {
opts.UnsupportedAttributesCheck = &UnsupportedAttributesCheck{Patterns: patterns, Report: report}
if opts.UnsupportedAttributesCheck == nil {
opts.UnsupportedAttributesCheck = &UnsupportedAttributesCheck{}
}
opts.UnsupportedAttributesCheck.Patterns = patterns
opts.UnsupportedAttributesCheck.Report = report
}
}

// WithSupportedAttributes declares the attribute paths the caller's runtime
// implements: every attribute of the loaded model matching none of them is
// reported, alongside any WithUnsupportedAttributesCheck findings, through
// the same report callback (report may be nil when the other option already
// set one). Extension keys (x-*) are never reported.
//
// This is the fail-closed side of the check: built by removing the
// unimplemented paths from schema.AttributePaths, it keeps reporting every
// newly-specified attribute until the runtime deliberately wires it in.
func WithSupportedAttributes(supported []tree.Path, report func([]UnsupportedAttribute)) func(*Options) {
return func(opts *Options) {
if opts.UnsupportedAttributesCheck == nil {
opts.UnsupportedAttributesCheck = &UnsupportedAttributesCheck{}
}
opts.UnsupportedAttributesCheck.Supported = supported
if report != nil {
opts.UnsupportedAttributesCheck.Report = report
}
}
}

// detectUnsupportedAttributes walks dict and returns every match against
// patterns, ordered by Path. The walk itself visits map keys in Go's
// detectUnsupportedAttributes walks dict once and returns every finding from
// both rule sets, ordered by Path. The walk itself visits map keys in Go's
// randomized order, so results are sorted here for deterministic output.
func detectUnsupportedAttributes(dict map[string]any, patterns []UnsupportedAttributePattern) []UnsupportedAttribute {
findings := walkUnsupportedAttributes(dict, tree.NewPath(), patterns)
func detectUnsupportedAttributes(dict map[string]any, check *UnsupportedAttributesCheck) []UnsupportedAttribute {
var supported *tree.Matcher
if len(check.Supported) > 0 {
supported = tree.NewMatcher(check.Supported...)
}
findings := walkUnsupportedAttributes(dict, tree.NewPath(), check.Patterns, supported)
slices.SortFunc(findings, func(a, b UnsupportedAttribute) int {
return cmp.Compare(a.Path, b.Path)
})
return findings
}

func walkUnsupportedAttributes(value any, p tree.Path, patterns []UnsupportedAttributePattern) []UnsupportedAttribute {
func walkUnsupportedAttributes(value any, p tree.Path, patterns []UnsupportedAttributePattern, supported *tree.Matcher) []UnsupportedAttribute {
matched := false
for _, pattern := range patterns {
if !p.Matches(pattern.Path) {
Expand All @@ -102,11 +145,27 @@ func walkUnsupportedAttributes(value any, p tree.Path, patterns []UnsupportedAtt
switch v := value.(type) {
case map[string]any:
for k, e := range v {
findings = append(findings, walkUnsupportedAttributes(e, p.Next(k), patterns)...)
next := p.Next(k)
// Extension keys are specification-blessed escape hatches: the
// allowlist never reports them nor anything underneath (deny
// patterns still apply below, so the walk continues without the
// matcher).
if strings.HasPrefix(k, "x-") {
findings = append(findings, walkUnsupportedAttributes(e, next, patterns, nil)...)
continue
}
// allowlist screening: an attribute neither declared (exact
// match) nor holding declared attributes deeper (MayContain) is
// reported once, undescended
if supported != nil && !supported.Matches(next) && !supported.MayContain(next) {
findings = append(findings, UnsupportedAttribute{Path: next, Value: e})
continue
}
findings = append(findings, walkUnsupportedAttributes(e, next, patterns, supported)...)
}
case []any:
for _, e := range v {
findings = append(findings, walkUnsupportedAttributes(e, p.Next(tree.PathMatchList), patterns)...)
findings = append(findings, walkUnsupportedAttributes(e, p.Next(tree.PathMatchList), patterns, supported)...)
}
}
return findings
Expand Down
133 changes: 128 additions & 5 deletions loader/unsupported_attributes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ package loader

import (
"context"
"slices"
"strings"
"testing"

"github.com/compose-spec/compose-go/v2/schema"
"github.com/compose-spec/compose-go/v2/tree"
"github.com/compose-spec/compose-go/v2/types"
"gotest.tools/v3/assert"
Expand All @@ -44,7 +47,7 @@ func TestDetectUnsupportedAttributes_PathPresence(t *testing.T) {
{Path: tree.NewPath("services", "*", "deploy", "mode")},
}

findings := detectUnsupportedAttributes(dict, patterns)
findings := detectUnsupportedAttributes(dict, &UnsupportedAttributesCheck{Patterns: patterns})

assert.Assert(t, is.Len(findings, 1))
assert.Equal(t, findings[0].Path, tree.NewPath("services", "web", "deploy", "mode"))
Expand Down Expand Up @@ -75,7 +78,7 @@ func TestDetectUnsupportedAttributes_ValueConditional(t *testing.T) {
},
}

findings := detectUnsupportedAttributes(dict, patterns)
findings := detectUnsupportedAttributes(dict, &UnsupportedAttributesCheck{Patterns: patterns})

assert.Assert(t, is.Len(findings, 1))
assert.Equal(t, findings[0].Path, tree.NewPath("services", "web", "ports", "[]"))
Expand All @@ -93,7 +96,7 @@ func TestDetectUnsupportedAttributes_NoPatternsMatch(t *testing.T) {
{Path: tree.NewPath("services", "*", "deploy", "mode")},
}

findings := detectUnsupportedAttributes(dict, patterns)
findings := detectUnsupportedAttributes(dict, &UnsupportedAttributesCheck{Patterns: patterns})

assert.Assert(t, is.Len(findings, 0))
}
Expand All @@ -116,7 +119,7 @@ func TestDetectUnsupportedAttributes_DoesNotDescendIntoMatchedNode(t *testing.T)
{Path: tree.NewPath("services", "*", "ports", "[]", "mode")},
}

findings := detectUnsupportedAttributes(dict, patterns)
findings := detectUnsupportedAttributes(dict, &UnsupportedAttributesCheck{Patterns: patterns})

assert.Assert(t, is.Len(findings, 1))
assert.Equal(t, findings[0].Path, tree.NewPath("services", "web", "ports", "[]"))
Expand Down Expand Up @@ -157,7 +160,7 @@ func TestDetectUnsupportedAttributes_AllPatternsAtSamePathAreEvaluated(t *testin
},
}

findings := detectUnsupportedAttributes(dict, patterns)
findings := detectUnsupportedAttributes(dict, &UnsupportedAttributesCheck{Patterns: patterns})

assert.Assert(t, is.Len(findings, 1))
assert.Equal(t, findings[0].Path, tree.NewPath("services", "web", "depends_on", "api"))
Expand Down Expand Up @@ -241,3 +244,123 @@ services:
assert.Equal(t, reported[0].Path, tree.NewPath("services", "web", "deploy", "mode"))
})
}

// The allowlist side: the runtime declares the full specification inventory
// minus what it does not implement, and the same report receives every
// undeclared attribute — fail-closed, so a newly-specified attribute keeps
// being reported until it is deliberately wired in.
func TestDetectUnsupportedAttributes_SupportedAllowlist(t *testing.T) {
supported := slices.DeleteFunc(slices.Clone(schema.AttributePaths()), func(p tree.Path) bool {
return strings.HasPrefix(string(p), "services.*.credential_spec") ||
p == "services.*.deploy.update_config.failure_action" ||
p == "services.*.deploy.update_config.parallelism"
})

var reported []UnsupportedAttribute
_, err := LoadWithContext(context.Background(), types.ConfigDetails{
ConfigFiles: []types.ConfigFile{{Filename: "compose.yaml", Content: []byte(`
services:
app:
image: myapp
environment:
DEBUG: "1"
deploy:
replicas: 2
update_config:
parallelism: 1
failure_action: rollback
credential_spec:
file: creds.json
x-custom:
anything: goes
`)}},
}, func(options *Options) {
options.SetProjectName("screening", true)
options.ResolvePaths = false
}, WithSupportedAttributes(supported, func(found []UnsupportedAttribute) {
reported = found
}))
assert.NilError(t, err)

paths := make([]string, len(reported))
for i, f := range reported {
paths[i] = f.Path.String()
}
// subtree removal reports once at its root, leaf removal reports each
// leaf (declared siblings keep the descent open); x-* untouched
assert.DeepEqual(t, paths, []string{
"services.app.credential_spec",
"services.app.deploy.update_config.failure_action",
"services.app.deploy.update_config.parallelism",
})
assert.Equal(t, reported[1].Value, "rollback")
}

// Both rule sets feed one walk and one report: deny patterns (value
// predicates included) and the allowlist compose.
func TestDetectUnsupportedAttributes_CombinedRules(t *testing.T) {
dict := map[string]any{
"services": map[string]any{
"web": map[string]any{
"image": "nginx",
"ports": []any{
map[string]any{"target": 80, "mode": "host"},
},
"credential_spec": map[string]any{"file": "creds.json"},
},
},
}
check := &UnsupportedAttributesCheck{
Patterns: []UnsupportedAttributePattern{{
Path: tree.NewPath("services", "*", "ports", "[]", "mode"),
Detect: func(value any) bool {
return value == "host"
},
}},
Supported: slices.DeleteFunc(slices.Clone(schema.AttributePaths()), func(p tree.Path) bool {
return strings.HasPrefix(string(p), "services.*.credential_spec")
}),
}

findings := detectUnsupportedAttributes(dict, check)

assert.Assert(t, is.Len(findings, 2))
assert.Equal(t, findings[0].Path, tree.NewPath("services", "web", "credential_spec"))
assert.Equal(t, findings[1].Path, tree.NewPath("services", "web", "ports", "[]", "mode"))
assert.Equal(t, findings[1].Value, "host")
}

// A fully supported file passes the complete inventory silently: the
// allowlist introduces no false positives on canonical forms.
func TestDetectUnsupportedAttributes_AllowlistNoFalsePositives(t *testing.T) {
var reported []UnsupportedAttribute
_, err := LoadWithContext(context.Background(), types.ConfigDetails{
ConfigFiles: []types.ConfigFile{{Filename: "compose.yaml", Content: []byte(`
services:
db:
image: mysql:8
volumes:
- data:/var/lib/mysql
networks:
- backend
healthcheck:
test: ["CMD", "mysqladmin", "ping"]
interval: 10s
deploy:
resources:
limits:
memory: 1g
volumes:
data:
networks:
backend:
`)}},
}, func(options *Options) {
options.SetProjectName("clean", true)
options.ResolvePaths = false
}, WithSupportedAttributes(schema.AttributePaths(), func(found []UnsupportedAttribute) {
reported = found
}))
assert.NilError(t, err)
assert.Assert(t, is.Len(reported, 0))
}
Loading