Skip to content
Open
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
100 changes: 99 additions & 1 deletion src/internal/packager/helm/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"path/filepath"

"github.com/defenseunicorns/pkg/helpers/v2"
"github.com/goccy/go-yaml/ast"
"github.com/goccy/go-yaml/parser"
"github.com/zarf-dev/zarf/src/api/v1alpha1"
"github.com/zarf-dev/zarf/src/config"
"github.com/zarf-dev/zarf/src/internal/packager/template"
Expand Down Expand Up @@ -167,7 +169,103 @@ func getTemplatedManifests(renderedManifests *bytes.Buffer, variableConfig *vari
releaseutil.InstallOrder,
)
if err != nil {
return nil, nil, fmt.Errorf("error re-rendering helm output: %w", err)
// STOPGAP (zarf #4977, fixed upstream by helm/helm#32204): Helm v4 splits
// kind:List items into separate documents but keeps YAML anchors, dangling
// any alias that crosses items. Repair the anchor scope and retry once.
if repaired, rerr := resolveCrossDocumentAnchors(buff); rerr == nil && repaired != nil {
hooks, resources, err = releaseutil.SortManifests(map[string]string{path: string(repaired)},
actionConfig.Capabilities.APIVersions,
releaseutil.InstallOrder,
)
}
if err != nil {
return nil, nil, fmt.Errorf("error re-rendering helm output: %w", err)
}
}
return hooks, resources, nil
}

// resolveCrossDocumentAnchors materializes YAML aliases against anchors collected
// across the whole stream, returning (nil, nil) when there is nothing to repair.
//
// STOPGAP for zarf #4977 (fixed upstream by helm/helm#32204); remove once a fixed
// Helm is vendored.
func resolveCrossDocumentAnchors(content []byte) ([]byte, error) {
// goccy's parser tolerates the dangling alias that yaml.v3 rejects at decode time.
file, err := parser.ParseBytes(content, 0)
if err != nil {
return nil, err
}

collector := &anchorCollector{anchors: map[string]ast.Node{}}
for _, doc := range file.Docs {
if doc.Body != nil {
ast.Walk(collector, doc.Body)
}
}
if len(collector.anchors) == 0 {
return nil, nil
}

resolver := &aliasResolver{anchors: collector.anchors}
for _, doc := range file.Docs {
if doc.Body != nil {
ast.Walk(resolver, doc.Body)
}
}
if resolver.resolved == 0 {
return nil, nil
}

return []byte(file.String()), nil
}

// anchorCollector walks a YAML AST collecting anchor definitions keyed by name.
type anchorCollector struct {
anchors map[string]ast.Node
}

func (c *anchorCollector) Visit(node ast.Node) ast.Visitor {
if anchor, ok := node.(*ast.AnchorNode); ok && anchor.Name != nil && anchor.Value != nil {
c.anchors[anchor.Name.String()] = anchor.Value
}
return c
}

// aliasResolver walks a YAML AST replacing alias references with the matching
// anchor's value node, in place.
type aliasResolver struct {
anchors map[string]ast.Node
resolved int
}

// resolve returns the anchor value node for an alias, or nil if node is not a
// resolvable alias.
func (r *aliasResolver) resolve(node ast.Node) ast.Node {
alias, ok := node.(*ast.AliasNode)
if !ok || alias.Value == nil {
return nil
}
value, found := r.anchors[alias.Value.String()]
if !found {
return nil
}
r.resolved++
return value
}

func (r *aliasResolver) Visit(node ast.Node) ast.Visitor {
switch n := node.(type) {
case *ast.MappingValueNode:
if value := r.resolve(n.Value); value != nil {
n.Value = value
}
case *ast.SequenceNode:
for i, entry := range n.Values {
if value := r.resolve(entry); value != nil {
n.Values[i] = value
}
}
}
return r
}
101 changes: 101 additions & 0 deletions src/internal/packager/helm/template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ import (
"context"
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/require"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"

"github.com/zarf-dev/zarf/src/api/v1alpha1"
"github.com/zarf-dev/zarf/src/internal/packager/template"
"github.com/zarf-dev/zarf/src/types"
Expand Down Expand Up @@ -39,3 +42,101 @@ func TestChartTemplate(t *testing.T) {
require.NoError(t, err)
require.YAMLEq(t, string(b), manifest)
}

func TestResolveCrossDocumentAnchors(t *testing.T) {
t.Parallel()

// Mirrors the corrupted stream Helm v4's annotateAndMerge produces from a
// `kind: List` with a cross-item anchor: the anchor lands in document 1 and
// the alias in document 2, leaving the alias dangling (zarf #4977).
crossDocAnchor := `apiVersion: v1
kind: ConfigMap
metadata:
name: cm-a
data: &shared
key: value
---
apiVersion: v1
kind: ConfigMap
metadata:
name: cm-b
data: *shared
`

tests := []struct {
name string
content string
wantRepair bool // true if the helper should return repaired bytes
wantContent map[string]string
}{
{
name: "cross-document mapping alias is materialized",
content: crossDocAnchor,
wantRepair: true,
wantContent: map[string]string{"key: value": ""},
},
{
name: "cross-document sequence alias is materialized",
content: `apiVersion: v1
kind: ConfigMap
metadata:
name: cm-a
data:
owner: &val zarf
---
apiVersion: v1
kind: ConfigMap
metadata:
name: cm-b
data:
owners:
- *val
`,
wantRepair: true,
wantContent: map[string]string{"- zarf": ""},
},
{
name: "no anchors is a no-op",
content: `apiVersion: v1
kind: ConfigMap
metadata:
name: cm-a
data:
key: value
`,
wantRepair: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

// The dangling alias must break the standard sorter, proving the repair is needed.
if tt.wantRepair {
_, _, err := releaseutil.SortManifests(map[string]string{"manifest": tt.content}, nil, releaseutil.InstallOrder)
require.Error(t, err, "expected unrepaired content to fail SortManifests")
}

repaired, err := resolveCrossDocumentAnchors([]byte(tt.content))
require.NoError(t, err)

if !tt.wantRepair {
require.Nil(t, repaired, "no-op input should return nil")
return
}

require.NotNil(t, repaired)
// The alias marker is gone and the repaired stream parses cleanly.
require.NotContains(t, string(repaired), "*")
_, resources, err := releaseutil.SortManifests(map[string]string{"manifest": string(repaired)}, nil, releaseutil.InstallOrder)
require.NoError(t, err, "repaired content should parse: %s", string(repaired))
require.Len(t, resources, 2)

for want := range tt.wantContent {
require.True(t, strings.Contains(resources[0].Content, want) || strings.Contains(resources[1].Content, want),
"materialized value %q not found in resources", want)
}
})
}
}
27 changes: 27 additions & 0 deletions src/test/e2e/27_deploy_regression_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package test

import (
"fmt"
"path/filepath"
"testing"

"github.com/stretchr/testify/require"
Expand All @@ -30,3 +31,29 @@ func TestGHCRDeploy(t *testing.T) {
stdOut, stdErr, err = e2e.Zarf(t, "package", "remove", "dos-games", "--confirm")
require.NoError(t, err, stdOut, stdErr)
}

// TestKindListAnchor is a regression test for #4977: a chart rendering a
// `kind: List` whose items share a YAML anchor used to fail post-render with
// "unknown anchor 'shared' referenced" under Helm v4's annotateAndMerge.
func TestKindListAnchor(t *testing.T) {
t.Log("E2E: kind: List with cross-item YAML anchor")
tmpdir := t.TempDir()

pkgPath := filepath.Join("src", "test", "packages", "27-kind-list-anchor")
stdOut, stdErr, err := e2e.Zarf(t, "package", "create", pkgPath, "-o", tmpdir, "--skip-sbom", "--confirm")
require.NoError(t, err, stdOut, stdErr)

packagePath := filepath.Join(tmpdir, fmt.Sprintf("zarf-package-kind-list-anchor-%s-0.0.1.tar.zst", e2e.Arch))
stdOut, stdErr, err = e2e.Zarf(t, "package", "deploy", packagePath, "--confirm")
require.NoError(t, err, stdOut, stdErr)

// Both ConfigMaps should resolve the anchor to data.key = value.
kubectlOut, _, err := e2e.Kubectl(t, "get", "configmap", "cm-a", "cm-b", "-n", "kind-list-anchor",
"-o", "jsonpath={range .items[*]}{.metadata.name}={.data.key} {end}")
require.NoError(t, err, kubectlOut)
require.Contains(t, kubectlOut, "cm-a=value")
require.Contains(t, kubectlOut, "cm-b=value")

stdOut, stdErr, err = e2e.Zarf(t, "package", "remove", "kind-list-anchor", "--confirm")
require.NoError(t, err, stdOut, stdErr)
}
3 changes: 3 additions & 0 deletions src/test/packages/27-kind-list-anchor/min-chart/Chart.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
apiVersion: v2
name: min-chart
version: 0.1.0
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
apiVersion: v1
kind: List
items:
- apiVersion: v1
kind: ConfigMap
metadata:
name: cm-a
data: &shared
key: value
- apiVersion: v1
kind: ConfigMap
metadata:
name: cm-b
data: *shared
14 changes: 14 additions & 0 deletions src/test/packages/27-kind-list-anchor/zarf.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
kind: ZarfPackageConfig
metadata:
name: kind-list-anchor
version: "0.0.1"
# Regression coverage for #4977: a kind: List with a YAML anchor crossing item
# boundaries broke Zarf's post-render under Helm v4's annotateAndMerge.
components:
- name: chart
required: true
charts:
- name: min-chart
namespace: kind-list-anchor
localPath: min-chart
version: 0.1.0