Skip to content

Commit ab50b76

Browse files
committed
refactored to rely on yaml.Node during parsing
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
1 parent 9dee8f4 commit ab50b76

23 files changed

+3595
-3
lines changed

interpolation/node.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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 interpolation
18+
19+
import (
20+
"fmt"
21+
"os"
22+
"strings"
23+
24+
"go.yaml.in/yaml/v4"
25+
26+
"github.com/compose-spec/compose-go/v2/template"
27+
"github.com/compose-spec/compose-go/v2/tree"
28+
)
29+
30+
// InterpolateNode replaces variables in yaml.Node scalar values
31+
func InterpolateNode(node *yaml.Node, opts Options) error {
32+
if opts.LookupValue == nil {
33+
opts.LookupValue = os.LookupEnv
34+
}
35+
if opts.TypeCastMapping == nil {
36+
opts.TypeCastMapping = make(map[tree.Path]Cast)
37+
}
38+
if opts.Substitute == nil {
39+
opts.Substitute = template.Substitute
40+
}
41+
return recursiveInterpolateNode(node, tree.NewPath(), opts)
42+
}
43+
44+
func recursiveInterpolateNode(node *yaml.Node, path tree.Path, opts Options) error {
45+
switch node.Kind {
46+
case yaml.DocumentNode:
47+
if len(node.Content) > 0 {
48+
return recursiveInterpolateNode(node.Content[0], path, opts)
49+
}
50+
return nil
51+
52+
case yaml.MappingNode:
53+
for i := 0; i+1 < len(node.Content); i += 2 {
54+
key := node.Content[i]
55+
value := node.Content[i+1]
56+
if err := recursiveInterpolateNode(value, path.Next(key.Value), opts); err != nil {
57+
return err
58+
}
59+
}
60+
return nil
61+
62+
case yaml.SequenceNode:
63+
for _, item := range node.Content {
64+
if err := recursiveInterpolateNode(item, path.Next(tree.PathMatchList), opts); err != nil {
65+
return err
66+
}
67+
}
68+
return nil
69+
70+
case yaml.ScalarNode:
71+
if node.Tag != "!!str" && node.Tag != "" && !strings.Contains(node.Value, "$") {
72+
return nil
73+
}
74+
newValue, err := opts.Substitute(node.Value, template.Mapping(opts.LookupValue))
75+
if err != nil {
76+
return newPathError(path, err)
77+
}
78+
caster, ok := opts.getCasterForPath(path)
79+
if !ok {
80+
if newValue != node.Value {
81+
node.Value = newValue
82+
}
83+
return nil
84+
}
85+
casted, err := caster(newValue)
86+
if err != nil {
87+
return newPathError(path, fmt.Errorf("failed to cast to expected type: %w", err))
88+
}
89+
switch casted.(type) {
90+
case bool:
91+
node.Tag = "!!bool"
92+
node.Value = fmt.Sprint(casted)
93+
case int, int64:
94+
node.Tag = "!!int"
95+
node.Value = fmt.Sprint(casted)
96+
case float64:
97+
node.Tag = "!!float"
98+
node.Value = fmt.Sprint(casted)
99+
case nil:
100+
node.Tag = "!!null"
101+
node.Value = "null"
102+
case string:
103+
node.Value = fmt.Sprint(casted)
104+
default:
105+
node.Value = fmt.Sprint(casted)
106+
}
107+
return nil
108+
109+
default:
110+
return nil
111+
}
112+
}

interpolation/node_test.go

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
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 interpolation
18+
19+
import (
20+
"encoding/json"
21+
"strconv"
22+
"testing"
23+
24+
"github.com/compose-spec/compose-go/v2/tree"
25+
"go.yaml.in/yaml/v4"
26+
"gotest.tools/v3/assert"
27+
)
28+
29+
func TestInterpolateNode_Simple(t *testing.T) {
30+
input := `
31+
services:
32+
web:
33+
image: ${IMAGE}
34+
`
35+
lookup := func(key string) (string, bool) {
36+
if key == "IMAGE" {
37+
return "nginx", true
38+
}
39+
return "", false
40+
}
41+
42+
var node yaml.Node
43+
assert.NilError(t, yaml.Unmarshal([]byte(input), &node))
44+
err := InterpolateNode(&node, Options{LookupValue: lookup})
45+
assert.NilError(t, err)
46+
47+
var result map[string]interface{}
48+
assert.NilError(t, node.Decode(&result))
49+
50+
services := result["services"].(map[string]interface{})
51+
web := services["web"].(map[string]interface{})
52+
assert.Equal(t, "nginx", web["image"])
53+
}
54+
55+
func TestInterpolateNode_Default(t *testing.T) {
56+
input := `
57+
services:
58+
web:
59+
image: ${IMAGE:-default}
60+
`
61+
lookup := func(key string) (string, bool) {
62+
return "", false
63+
}
64+
65+
var node yaml.Node
66+
assert.NilError(t, yaml.Unmarshal([]byte(input), &node))
67+
err := InterpolateNode(&node, Options{LookupValue: lookup})
68+
assert.NilError(t, err)
69+
70+
var result map[string]interface{}
71+
assert.NilError(t, node.Decode(&result))
72+
73+
services := result["services"].(map[string]interface{})
74+
web := services["web"].(map[string]interface{})
75+
assert.Equal(t, "default", web["image"])
76+
}
77+
78+
func TestInterpolateNode_NoSubstitution(t *testing.T) {
79+
input := `
80+
services:
81+
web:
82+
image: nginx
83+
ports:
84+
- "8080"
85+
`
86+
lookup := func(key string) (string, bool) {
87+
return "", false
88+
}
89+
90+
var node yaml.Node
91+
assert.NilError(t, yaml.Unmarshal([]byte(input), &node))
92+
93+
// Take a snapshot before interpolation
94+
var before map[string]interface{}
95+
assert.NilError(t, node.Decode(&before))
96+
97+
err := InterpolateNode(&node, Options{LookupValue: lookup})
98+
assert.NilError(t, err)
99+
100+
var after map[string]interface{}
101+
assert.NilError(t, node.Decode(&after))
102+
103+
beforeJSON, _ := json.Marshal(before)
104+
afterJSON, _ := json.Marshal(after)
105+
assert.Equal(t, string(beforeJSON), string(afterJSON))
106+
}
107+
108+
func TestInterpolateNode_TypeCast(t *testing.T) {
109+
input := `
110+
services:
111+
web:
112+
ports:
113+
- ${PORT}
114+
`
115+
lookup := func(key string) (string, bool) {
116+
if key == "PORT" {
117+
return "8080", true
118+
}
119+
return "", false
120+
}
121+
122+
toInt := func(value string) (interface{}, error) {
123+
return strconv.Atoi(value)
124+
}
125+
126+
var node yaml.Node
127+
assert.NilError(t, yaml.Unmarshal([]byte(input), &node))
128+
err := InterpolateNode(&node, Options{
129+
LookupValue: lookup,
130+
TypeCastMapping: map[tree.Path]Cast{
131+
tree.NewPath("services", tree.PathMatchAll, "ports", tree.PathMatchList): toInt,
132+
},
133+
})
134+
assert.NilError(t, err)
135+
136+
var result map[string]interface{}
137+
assert.NilError(t, node.Decode(&result))
138+
139+
services := result["services"].(map[string]interface{})
140+
web := services["web"].(map[string]interface{})
141+
ports := web["ports"].([]interface{})
142+
assert.Equal(t, 8080, ports[0])
143+
}
144+
145+
func TestInterpolateNode_Parity(t *testing.T) {
146+
input := `
147+
services:
148+
web:
149+
image: ${IMAGE}
150+
environment:
151+
FOO: ${FOO_VAL}
152+
BAR: ${BAR_VAL:-default_bar}
153+
labels:
154+
version: ${VERSION}
155+
`
156+
env := map[string]string{
157+
"IMAGE": "nginx",
158+
"FOO_VAL": "hello",
159+
"VERSION": "1.0",
160+
}
161+
testInterpolateParity(t, input, env)
162+
}
163+
164+
func testInterpolateParity(t *testing.T, input string, env map[string]string) {
165+
t.Helper()
166+
lookup := func(key string) (string, bool) {
167+
v, ok := env[key]
168+
return v, ok
169+
}
170+
opts := Options{
171+
LookupValue: lookup,
172+
}
173+
174+
// Map-based
175+
var mapData map[string]interface{}
176+
assert.NilError(t, yaml.Unmarshal([]byte(input), &mapData))
177+
mapResult, err := Interpolate(mapData, opts)
178+
assert.NilError(t, err)
179+
180+
// Node-based
181+
var node yaml.Node
182+
assert.NilError(t, yaml.Unmarshal([]byte(input), &node))
183+
err = InterpolateNode(&node, opts)
184+
assert.NilError(t, err)
185+
186+
var nodeMap map[string]interface{}
187+
assert.NilError(t, node.Decode(&nodeMap))
188+
189+
// Compare via JSON
190+
mapJSON, _ := json.Marshal(mapResult)
191+
nodeJSON, _ := json.Marshal(nodeMap)
192+
assert.Equal(t, string(mapJSON), string(nodeJSON))
193+
}
194+
195+
func TestInterpolateNode_Error(t *testing.T) {
196+
input := `
197+
services:
198+
web:
199+
image: ${IMAGE:?}
200+
`
201+
lookup := func(key string) (string, bool) {
202+
return "", false
203+
}
204+
205+
var node yaml.Node
206+
assert.NilError(t, yaml.Unmarshal([]byte(input), &node))
207+
err := InterpolateNode(&node, Options{LookupValue: lookup})
208+
assert.Assert(t, err != nil, "expected an error for missing required variable")
209+
}

0 commit comments

Comments
 (0)