Skip to content

Commit 26448a2

Browse files
ndeloofclaude
andcommitted
validation,loader: add ValidateNode and NormalizeNode
ValidateNode is a native port of validation.Validate to *yaml.Node. Each entry of the v2 checks map gets a Node twin in nodeChecks: file mutual exclusion for configs / secrets, IP address validation for port host_ip, count vs device_ids exclusivity for deploy.resources and gpus, non-blank watch paths, and the external-with-extra-fields check for volumes via checkVolumeNode / checkExternalNode. Tag and Kind are read directly so callers no longer need to canonicalize before validating. NormalizeNode is a bridge to the map-based Normalize: it decodes root, runs Normalize, and rebuilds a yaml.Node from the result. This reuses the well-tested rule set (default network injection, build context defaults, implicit dependencies, env_file resolution, ...) without porting 260 lines of orchestration in a single commit. Source positions are lost on the rebuilt subtree; per-rule node-native ports will land in subsequent commits and narrow the bridge until it can be removed. Both functions tolerate a DocumentNode wrapper and nil root for defensive use by the orchestrator. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
1 parent 39f246a commit 26448a2

5 files changed

Lines changed: 502 additions & 0 deletions

File tree

loader/normalize_node.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
Copyright 2020 The Compose Specification Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package loader
18+
19+
import (
20+
"fmt"
21+
22+
"go.yaml.in/yaml/v4"
23+
24+
"github.com/compose-spec/compose-go/v3/types"
25+
)
26+
27+
// NormalizeNode injects implicit defaults (default networks, derived service
28+
// dependencies, build defaults, ...) into a parsed yaml.Node tree using the
29+
// same rules as Normalize.
30+
//
31+
// First cut: the function bridges through map[string]any — it decodes root,
32+
// runs Normalize, and rebuilds a yaml.Node from the result. This reuses the
33+
// well-tested per-rule logic of the v2 implementation while keeping the v3
34+
// pipeline honest end-to-end. Subsequent commits will port the individual
35+
// normalization steps (networks, dependencies, builds, ...) to operate on
36+
// *yaml.Node directly so that source positions survive normalization for
37+
// downstream diagnostics; until then the rebuilt subtree has Line / Column
38+
// zero on synthesized nodes (default network, derived depends_on entries).
39+
//
40+
// NormalizeNode mutates root in place: the inner Content of the document
41+
// wrapper is replaced with the encoded normalized tree. Returns root for
42+
// convenience.
43+
func NormalizeNode(root *yaml.Node, env types.Mapping) (*yaml.Node, error) {
44+
if root == nil {
45+
return nil, nil
46+
}
47+
target := root
48+
if target.Kind == yaml.DocumentNode && len(target.Content) == 1 {
49+
target = target.Content[0]
50+
}
51+
52+
var data map[string]any
53+
if err := target.Decode(&data); err != nil {
54+
return nil, fmt.Errorf("normalize: decode for bridge: %w", err)
55+
}
56+
57+
normalized, err := Normalize(data, env)
58+
if err != nil {
59+
return nil, err
60+
}
61+
62+
var rebuilt yaml.Node
63+
if err := rebuilt.Encode(normalized); err != nil {
64+
return nil, fmt.Errorf("normalize: re-encode after bridge: %w", err)
65+
}
66+
67+
*target = rebuilt
68+
return root, nil
69+
}

loader/normalize_node_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
Copyright 2020 The Compose Specification Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package loader
18+
19+
import (
20+
"testing"
21+
22+
"go.yaml.in/yaml/v4"
23+
"gotest.tools/v3/assert"
24+
25+
"github.com/compose-spec/compose-go/v3/types"
26+
)
27+
28+
func parseNormalizeNode(t *testing.T, src string) *yaml.Node {
29+
t.Helper()
30+
var doc yaml.Node
31+
assert.NilError(t, yaml.Unmarshal([]byte(src), &doc))
32+
return &doc
33+
}
34+
35+
func decodeNormalize(t *testing.T, n *yaml.Node) map[string]any {
36+
t.Helper()
37+
var m map[string]any
38+
assert.NilError(t, n.Decode(&m))
39+
return m
40+
}
41+
42+
func TestNormalizeNode_InjectsDefaultNetwork(t *testing.T) {
43+
root := parseNormalizeNode(t, `
44+
name: app
45+
services:
46+
web:
47+
image: nginx
48+
`)
49+
out, err := NormalizeNode(root, types.Mapping{})
50+
assert.NilError(t, err)
51+
m := decodeNormalize(t, out)
52+
nets, ok := m["networks"].(map[string]any)
53+
assert.Assert(t, ok, "default network injected: %v", m["networks"])
54+
_, hasDefault := nets["default"]
55+
assert.Assert(t, hasDefault, "networks.default should be created")
56+
}
57+
58+
func TestNormalizeNode_BuildContextDefaultsToDot(t *testing.T) {
59+
root := parseNormalizeNode(t, `
60+
name: app
61+
services:
62+
web:
63+
build:
64+
dockerfile: Dockerfile
65+
`)
66+
out, err := NormalizeNode(root, types.Mapping{})
67+
assert.NilError(t, err)
68+
m := decodeNormalize(t, out)
69+
build := m["services"].(map[string]any)["web"].(map[string]any)["build"].(map[string]any)
70+
assert.Equal(t, build["context"], ".")
71+
}
72+
73+
func TestNormalizeNode_NilSafe(t *testing.T) {
74+
out, err := NormalizeNode(nil, types.Mapping{})
75+
assert.NilError(t, err)
76+
assert.Assert(t, out == nil)
77+
}

validation/node.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/*
2+
Copyright 2020 The Compose Specification Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package validation
18+
19+
import (
20+
"fmt"
21+
"net"
22+
"strings"
23+
24+
"go.yaml.in/yaml/v4"
25+
26+
"github.com/compose-spec/compose-go/v3/tree"
27+
)
28+
29+
type nodeChecker func(n *yaml.Node, p tree.Path) error
30+
31+
// nodeChecks mirrors `checks` but operates on *yaml.Node so the v3 pipeline
32+
// can validate the merged tree without round-tripping through map[string]any.
33+
// Entries stay in sync with the legacy map; the v2 map disappears when the
34+
// map-based code path is removed.
35+
var nodeChecks = map[tree.Path]nodeChecker{
36+
"volumes.*": checkVolumeNode,
37+
"configs.*": checkFileObjectNode("file", "environment", "content"),
38+
"secrets.*": checkFileObjectNode("file", "environment"),
39+
"services.*.ports.*": checkIPAddressNode,
40+
"services.*.develop.watch.*.path": checkPathNode,
41+
"services.*.deploy.resources.reservations.devices.*": checkDeviceRequestNode,
42+
"services.*.gpus.*": checkDeviceRequestNode,
43+
}
44+
45+
// ValidateNode walks root and applies the per-path validation checks. The
46+
// tree is not mutated; only errors are reported. The function returns at the
47+
// first failing check, with the offending tree.Path included in the error so
48+
// callers can map it back to a source location.
49+
func ValidateNode(root *yaml.Node) error {
50+
if root == nil {
51+
return nil
52+
}
53+
target := root
54+
if target.Kind == yaml.DocumentNode && len(target.Content) == 1 {
55+
target = target.Content[0]
56+
}
57+
return checkNode(target, tree.NewPath())
58+
}
59+
60+
func checkNode(n *yaml.Node, p tree.Path) error {
61+
if n == nil {
62+
return nil
63+
}
64+
for pattern, fn := range nodeChecks {
65+
if p.Matches(pattern) {
66+
return fn(n, p)
67+
}
68+
}
69+
switch n.Kind {
70+
case yaml.MappingNode:
71+
for i := 0; i+1 < len(n.Content); i += 2 {
72+
if err := checkNode(n.Content[i+1], p.Next(n.Content[i].Value)); err != nil {
73+
return err
74+
}
75+
}
76+
case yaml.SequenceNode:
77+
for _, c := range n.Content {
78+
if err := checkNode(c, p.Next(tree.PathMatchList)); err != nil {
79+
return err
80+
}
81+
}
82+
}
83+
return nil
84+
}
85+
86+
func checkFileObjectNode(keys ...string) nodeChecker {
87+
return func(n *yaml.Node, p tree.Path) error {
88+
if n == nil || n.Kind != yaml.MappingNode {
89+
return nil
90+
}
91+
count := 0
92+
for _, k := range keys {
93+
if mappingFieldNode(n, k) != nil {
94+
count++
95+
}
96+
}
97+
if count > 1 {
98+
return fmt.Errorf("%s: %s attributes are mutually exclusive", p, strings.Join(keys, "|"))
99+
}
100+
if count == 0 {
101+
if mappingFieldNode(n, "driver") != nil {
102+
// Custom driver: may carry its own content channel.
103+
return nil
104+
}
105+
if mappingFieldNode(n, "external") == nil {
106+
return fmt.Errorf("%s: one of %s must be set", p, strings.Join(keys, "|"))
107+
}
108+
}
109+
return nil
110+
}
111+
}
112+
113+
func checkPathNode(n *yaml.Node, p tree.Path) error {
114+
if n == nil || n.Kind != yaml.ScalarNode || n.Value == "" {
115+
return fmt.Errorf("%s: value can't be blank", p)
116+
}
117+
return nil
118+
}
119+
120+
func checkDeviceRequestNode(n *yaml.Node, p tree.Path) error {
121+
if n == nil || n.Kind != yaml.MappingNode {
122+
return nil
123+
}
124+
if mappingFieldNode(n, "count") != nil && mappingFieldNode(n, "device_ids") != nil {
125+
return fmt.Errorf(`%s: "count" and "device_ids" attributes are exclusive`, p)
126+
}
127+
return nil
128+
}
129+
130+
func checkIPAddressNode(n *yaml.Node, p tree.Path) error {
131+
if n == nil || n.Kind != yaml.MappingNode {
132+
return nil
133+
}
134+
ip := mappingFieldNode(n, "host_ip")
135+
if ip == nil || ip.Kind != yaml.ScalarNode {
136+
return nil
137+
}
138+
if net.ParseIP(ip.Value) == nil {
139+
return fmt.Errorf("%s: invalid ip address: %s", p, ip.Value)
140+
}
141+
return nil
142+
}
143+
144+
func mappingFieldNode(n *yaml.Node, key string) *yaml.Node {
145+
if n == nil || n.Kind != yaml.MappingNode {
146+
return nil
147+
}
148+
for i := 0; i+1 < len(n.Content); i += 2 {
149+
if n.Content[i].Value == key {
150+
return n.Content[i+1]
151+
}
152+
}
153+
return nil
154+
}

0 commit comments

Comments
 (0)