diff --git a/src/internal/packager/helm/template.go b/src/internal/packager/helm/template.go index 7bc0484f90..6f40066bb4 100644 --- a/src/internal/packager/helm/template.go +++ b/src/internal/packager/helm/template.go @@ -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" @@ -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 +} diff --git a/src/internal/packager/helm/template_test.go b/src/internal/packager/helm/template_test.go index ce0ab8c245..e2aa832b57 100644 --- a/src/internal/packager/helm/template_test.go +++ b/src/internal/packager/helm/template_test.go @@ -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" @@ -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) + } + }) + } +} diff --git a/src/test/e2e/27_deploy_regression_test.go b/src/test/e2e/27_deploy_regression_test.go index 81fb0b9d8a..eeeb3bda07 100644 --- a/src/test/e2e/27_deploy_regression_test.go +++ b/src/test/e2e/27_deploy_regression_test.go @@ -6,6 +6,7 @@ package test import ( "fmt" + "path/filepath" "testing" "github.com/stretchr/testify/require" @@ -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) +} diff --git a/src/test/packages/27-kind-list-anchor/min-chart/Chart.yaml b/src/test/packages/27-kind-list-anchor/min-chart/Chart.yaml new file mode 100644 index 0000000000..a2b9bf2790 --- /dev/null +++ b/src/test/packages/27-kind-list-anchor/min-chart/Chart.yaml @@ -0,0 +1,3 @@ +apiVersion: v2 +name: min-chart +version: 0.1.0 diff --git a/src/test/packages/27-kind-list-anchor/min-chart/templates/list.yaml b/src/test/packages/27-kind-list-anchor/min-chart/templates/list.yaml new file mode 100644 index 0000000000..866ce28728 --- /dev/null +++ b/src/test/packages/27-kind-list-anchor/min-chart/templates/list.yaml @@ -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 diff --git a/src/test/packages/27-kind-list-anchor/zarf.yaml b/src/test/packages/27-kind-list-anchor/zarf.yaml new file mode 100644 index 0000000000..2d6c35f0ea --- /dev/null +++ b/src/test/packages/27-kind-list-anchor/zarf.yaml @@ -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