Skip to content

Commit 2e2d635

Browse files
fix(cld): harden YAML merge key parsing for pipeline inputs
Return errors for invalid <<: targets, support sequence merges, and propagate YamlNodeToAny decode failures through the parse path.
1 parent f8befda commit 2e2d635

4 files changed

Lines changed: 251 additions & 32 deletions

File tree

.changeset/loud-trams-jog.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"chainlink-deployments-framework": minor
3+
---
4+
5+
feat(cld): enhance support for YAML anchors for pipeline input files

engine/cld/pipeline/input/resolve.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ import (
1313
// ResolveChangesetConfig resolves the configuration for a changeset using either
1414
// a registered resolver or keeping the original payload.
1515
func ResolveChangesetConfig(valueNode *yaml.Node, csName string, resolver resolvers.ConfigResolver) (any, error) {
16-
changesetMap, ok := YamlNodeToAny(valueNode).(map[string]any)
16+
changesetAny, err := yamlNodeToAny(valueNode)
17+
if err != nil {
18+
return nil, fmt.Errorf("decode changeset data for %s: %w", csName, err)
19+
}
20+
changesetMap, ok := changesetAny.(map[string]any)
1721
if !ok {
1822
return nil, fmt.Errorf("decode changeset data for %s: expected mapping node", csName)
1923
}

engine/cld/pipeline/input/yaml.go

Lines changed: 86 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,39 @@ func isYAMLMergeKey(node *yaml.Node) bool {
156156
return node != nil && node.Kind == yaml.ScalarNode && (node.Value == "<<" || node.Tag == "!!merge")
157157
}
158158

159+
func mergeMapInto(dst, src map[string]any) {
160+
for mk, mv := range src {
161+
if _, exists := dst[mk]; !exists {
162+
dst[mk] = mv
163+
}
164+
}
165+
}
166+
167+
func applyYAMLMerge(out map[string]any, value any) error {
168+
switch v := value.(type) {
169+
case map[string]any:
170+
mergeMapInto(out, v)
171+
172+
return nil
173+
case []any:
174+
seqMerged := make(map[string]any)
175+
for i, item := range v {
176+
mergeMap, ok := item.(map[string]any)
177+
if !ok {
178+
return fmt.Errorf("YAML merge sequence entry %d must be a mapping, got %T", i, item)
179+
}
180+
for mk, mv := range mergeMap {
181+
seqMerged[mk] = mv
182+
}
183+
}
184+
mergeMapInto(out, seqMerged)
185+
186+
return nil
187+
default:
188+
return fmt.Errorf("YAML merge key (<<) value must be a mapping or sequence of mappings, got %T", value)
189+
}
190+
}
191+
159192
// ConvertToJSONSafe recursively converts map[interface{}]interface{} to map[string]any.
160193
func ConvertToJSONSafe(data any) (any, error) {
161194
switch v := data.(type) {
@@ -311,7 +344,11 @@ func ParseYAMLBytes(yamlData []byte) (*DurablePipelineYAML, error) {
311344
if err := yaml.Unmarshal(yamlData, &root); err != nil {
312345
return nil, fmt.Errorf("failed to parse YAML bytes: %w", err)
313346
}
314-
rootMap, ok := YamlNodeToAny(&root).(map[string]any)
347+
rootAny, err := yamlNodeToAny(&root)
348+
if err != nil {
349+
return nil, fmt.Errorf("failed to decode YAML: %w", err)
350+
}
351+
rootMap, ok := rootAny.(map[string]any)
315352
if !ok {
316353
return nil, errors.New("expected a YAML object at the root")
317354
}
@@ -342,81 +379,103 @@ func ParseYAMLBytes(yamlData []byte) (*DurablePipelineYAML, error) {
342379
}
343380

344381
// YamlNodeToAny converts a yaml.Node to a generic any value.
382+
// This is the stable exported API; decode errors are ignored and nil is returned.
345383
func YamlNodeToAny(node *yaml.Node) any {
346-
if node == nil {
384+
v, err := yamlNodeToAny(node)
385+
if err != nil {
347386
return nil
348387
}
349388

389+
return v
390+
}
391+
392+
// yamlNodeToAny converts a yaml.Node to a generic any value.
393+
func yamlNodeToAny(node *yaml.Node) (any, error) {
394+
if node == nil {
395+
return nil, nil //nolint:nilnil // YAML null
396+
}
397+
350398
switch node.Kind {
351399
case yaml.DocumentNode:
352400
if len(node.Content) == 0 {
353-
return nil
401+
return nil, nil //nolint:nilnil // empty YAML document
354402
}
355403

356-
return YamlNodeToAny(node.Content[0])
404+
return yamlNodeToAny(node.Content[0])
357405
case yaml.MappingNode:
358406
out := make(map[string]any, len(node.Content)/2)
359407
for i := 0; i+1 < len(node.Content); i += 2 {
360408
key := node.Content[i]
361409
value := node.Content[i+1]
362410
if isYAMLMergeKey(key) {
363-
mergeMap, ok := YamlNodeToAny(value).(map[string]any)
364-
if !ok {
365-
continue
411+
merged, err := yamlNodeToAny(value)
412+
if err != nil {
413+
return nil, err
366414
}
367-
for mk, mv := range mergeMap {
368-
if _, exists := out[mk]; !exists {
369-
out[mk] = mv
370-
}
415+
if err := applyYAMLMerge(out, merged); err != nil {
416+
return nil, err
371417
}
372418

373419
continue
374420
}
375421

376-
keyStr := anyToJSONMapKey(YamlNodeToAny(key))
377-
out[keyStr] = YamlNodeToAny(value)
422+
keyAny, err := yamlNodeToAny(key)
423+
if err != nil {
424+
return nil, err
425+
}
426+
valueAny, err := yamlNodeToAny(value)
427+
if err != nil {
428+
return nil, err
429+
}
430+
keyStr := anyToJSONMapKey(keyAny)
431+
out[keyStr] = valueAny
378432
}
379433

380-
return out
434+
return out, nil
381435
case yaml.SequenceNode:
382436
out := make([]any, 0, len(node.Content))
383437
for _, elem := range node.Content {
384-
out = append(out, YamlNodeToAny(elem))
438+
elemAny, err := yamlNodeToAny(elem)
439+
if err != nil {
440+
return nil, err
441+
}
442+
out = append(out, elemAny)
385443
}
386444

387-
return out
445+
return out, nil
388446
case yaml.ScalarNode:
389447
if node.Style == 0 && decimalInteger.MatchString(node.Value) {
390-
return json.Number(node.Value)
448+
return json.Number(node.Value), nil
391449
}
392450

393451
switch node.Tag {
394452
case "!!int":
395453
if decimalInteger.MatchString(node.Value) {
396-
return json.Number(node.Value)
454+
return json.Number(node.Value), nil
397455
}
398456
if n, ok := new(big.Int).SetString(strings.ReplaceAll(node.Value, "_", ""), 0); ok {
399-
return json.Number(n.String())
457+
return json.Number(n.String()), nil
400458
}
401459

402-
return node.Value
460+
return node.Value, nil
403461
case "!!float":
404462
f, err := strconv.ParseFloat(node.Value, 64)
405463
if err != nil {
406-
return node.Value
464+
//nolint:nilerr // Fall back to raw scalar when float parsing fails.
465+
return node.Value, nil
407466
}
408467

409-
return f
468+
return f, nil
410469
case "!!null":
411-
return nil
470+
return nil, nil //nolint:nilnil // YAML null scalar
412471
case "!!bool":
413-
return strings.EqualFold(node.Value, "true")
472+
return strings.EqualFold(node.Value, "true"), nil
414473
default:
415-
return node.Value
474+
return node.Value, nil
416475
}
417476
case yaml.AliasNode:
418-
return YamlNodeToAny(node.Alias)
477+
return yamlNodeToAny(node.Alias)
419478
default:
420-
return nil
479+
return nil, nil //nolint:nilnil // unsupported node kind
421480
}
422481
}

engine/cld/pipeline/input/yaml_test.go

Lines changed: 155 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,11 @@ func TestConvertToJSONSafe(t *testing.T) {
166166
in: 42,
167167
want: 42,
168168
},
169+
{
170+
name: "map with json number key",
171+
in: map[interface{}]interface{}{json.Number("16015286601757825753"): "chain"},
172+
want: map[string]any{"16015286601757825753": "chain"},
173+
},
169174
}
170175

171176
for _, tt := range tests {
@@ -182,8 +187,7 @@ func TestConvertToJSONSafe(t *testing.T) {
182187
func TestYamlNodeToAny_Nil(t *testing.T) {
183188
t.Parallel()
184189

185-
got := YamlNodeToAny(nil)
186-
require.Nil(t, got)
190+
require.Nil(t, YamlNodeToAny(nil))
187191
}
188192

189193
func TestYamlNodeToAny_AliasMapKeys(t *testing.T) {
@@ -201,7 +205,9 @@ chains:
201205
var root yaml.Node
202206
require.NoError(t, yaml.Unmarshal([]byte(yamlContent), &root))
203207

204-
doc := YamlNodeToAny(&root).(map[string]any)
208+
docAny, err := yamlNodeToAny(&root)
209+
require.NoError(t, err)
210+
doc := docAny.(map[string]any)
205211
chains := doc["chains"].(map[string]any)
206212

207213
require.Equal(t, map[string]any{
@@ -232,7 +238,9 @@ chains:
232238
var root yaml.Node
233239
require.NoError(t, yaml.Unmarshal([]byte(yamlContent), &root))
234240

235-
doc := YamlNodeToAny(&root).(map[string]any)
241+
docAny, err := yamlNodeToAny(&root)
242+
require.NoError(t, err)
243+
doc := docAny.(map[string]any)
236244
chains := doc["chains"].(map[string]any)
237245
chain := chains["16015286601757825753"].(map[string]any)
238246

@@ -241,6 +249,149 @@ chains:
241249
require.Equal(t, map[string]any{"quorum": json.Number("1")}, chain["proposer"])
242250
}
243251

252+
func TestYamlNodeToAny_MergeKeyOverrideAfter(t *testing.T) {
253+
t.Parallel()
254+
255+
yamlContent := `
256+
base: &base_cfg
257+
qualifier: Base
258+
timelockMinDelay: 0
259+
chains:
260+
"123":
261+
<<: *base_cfg
262+
qualifier: Override
263+
`
264+
var root yaml.Node
265+
require.NoError(t, yaml.Unmarshal([]byte(yamlContent), &root))
266+
267+
docAny, err := yamlNodeToAny(&root)
268+
require.NoError(t, err)
269+
chain := docAny.(map[string]any)["chains"].(map[string]any)["123"].(map[string]any)
270+
271+
require.Equal(t, "Override", chain["qualifier"])
272+
require.Equal(t, json.Number("0"), chain["timelockMinDelay"])
273+
}
274+
275+
func TestYamlNodeToAny_MergeKeyExplicitBeforeMerge(t *testing.T) {
276+
t.Parallel()
277+
278+
yamlContent := `
279+
base: &base_cfg
280+
qualifier: Base
281+
timelockMinDelay: 0
282+
chains:
283+
"123":
284+
qualifier: Override
285+
<<: *base_cfg
286+
`
287+
var root yaml.Node
288+
require.NoError(t, yaml.Unmarshal([]byte(yamlContent), &root))
289+
290+
docAny, err := yamlNodeToAny(&root)
291+
require.NoError(t, err)
292+
chain := docAny.(map[string]any)["chains"].(map[string]any)["123"].(map[string]any)
293+
294+
require.Equal(t, "Override", chain["qualifier"])
295+
require.Equal(t, json.Number("0"), chain["timelockMinDelay"])
296+
}
297+
298+
func TestYamlNodeToAny_MergeKeySequence(t *testing.T) {
299+
t.Parallel()
300+
301+
yamlContent := `
302+
a: &a
303+
x: 1
304+
b: &b
305+
y: 2
306+
m:
307+
<<: [*a, *b]
308+
z: 3
309+
`
310+
var root yaml.Node
311+
require.NoError(t, yaml.Unmarshal([]byte(yamlContent), &root))
312+
313+
docAny, err := yamlNodeToAny(&root)
314+
require.NoError(t, err)
315+
m := docAny.(map[string]any)["m"].(map[string]any)
316+
317+
require.Equal(t, json.Number("1"), m["x"])
318+
require.Equal(t, json.Number("2"), m["y"])
319+
require.Equal(t, json.Number("3"), m["z"])
320+
}
321+
322+
func TestYamlNodeToAny_MergeKeySequenceConflict(t *testing.T) {
323+
t.Parallel()
324+
325+
yamlContent := `
326+
a: &a
327+
x: from_a
328+
only_a: 1
329+
b: &b
330+
x: from_b
331+
only_b: 2
332+
m:
333+
<<: [*a, *b]
334+
`
335+
var root yaml.Node
336+
require.NoError(t, yaml.Unmarshal([]byte(yamlContent), &root))
337+
338+
docAny, err := yamlNodeToAny(&root)
339+
require.NoError(t, err)
340+
m := docAny.(map[string]any)["m"].(map[string]any)
341+
342+
require.Equal(t, "from_b", m["x"])
343+
require.Equal(t, json.Number("1"), m["only_a"])
344+
require.Equal(t, json.Number("2"), m["only_b"])
345+
}
346+
347+
func TestYamlNodeToAny_InvalidMergeValueSwallowsError(t *testing.T) {
348+
t.Parallel()
349+
350+
yamlContent := `
351+
m:
352+
<<: not_a_map
353+
z: 3
354+
`
355+
var root yaml.Node
356+
require.NoError(t, yaml.Unmarshal([]byte(yamlContent), &root))
357+
358+
require.Nil(t, YamlNodeToAny(&root))
359+
}
360+
361+
func TestYamlNodeToAny_InvalidMergeValue(t *testing.T) {
362+
t.Parallel()
363+
364+
yamlContent := `
365+
m:
366+
<<: not_a_map
367+
z: 3
368+
`
369+
var root yaml.Node
370+
require.NoError(t, yaml.Unmarshal([]byte(yamlContent), &root))
371+
372+
_, err := yamlNodeToAny(&root)
373+
require.Error(t, err)
374+
require.ErrorContains(t, err, "YAML merge key (<<) value must be a mapping or sequence of mappings")
375+
}
376+
377+
func TestParseYAMLBytes_InvalidMergeValue(t *testing.T) {
378+
t.Parallel()
379+
380+
yamlContent := `environment: testnet
381+
domain: ccv
382+
changesets:
383+
- cs1:
384+
payload:
385+
m:
386+
<<: not_a_map
387+
z: 3
388+
`
389+
_, err := ParseYAMLBytes([]byte(yamlContent))
390+
require.Error(t, err)
391+
require.ErrorContains(t, err, "failed to decode YAML")
392+
require.ErrorContains(t, err, "YAML merge key (<<) value must be a mapping or sequence of mappings")
393+
}
394+
244395
func TestBuildChangesetInputJSON_AliasChainSelectorKeys(t *testing.T) {
245396
t.Parallel()
246397

0 commit comments

Comments
 (0)