Skip to content
Closed
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
125 changes: 125 additions & 0 deletions internal/postrender/anchors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/*
Copyright 2026 The Flux authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package postrender

import (
"bytes"
"fmt"
"strings"

"sigs.k8s.io/kustomize/kyaml/kio"
)

// inflateYAMLAliases expands YAML anchors/aliases in a multi-document stream.
//
// Helm v4's post-render merge (annotateAndMerge) unwraps kind:List into
// individual documents while preserving YAML anchors. That turns in-List
// aliases into cross-document references, which standard YAML parsers reject
// ("unknown anchor referenced"). Re-wrapping into a List restores a single
// document scope so aliases resolve, then DeAnchor inlines them permanently.
//
// If the input already parses cleanly, it is returned unchanged to avoid
// reformatting manifests.
func inflateYAMLAliases(in []byte) ([]byte, error) {
if len(bytes.TrimSpace(in)) == 0 {
return in, nil
}

if _, err := kio.ParseAll(string(in)); err == nil {
return in, nil
} else if !isUnknownAnchorErr(err) {
return nil, err
}

wrapped := wrapDocsAsList(string(in))
nodes, err := kio.ParseAll(wrapped)
if err != nil {
return nil, fmt.Errorf("inflate YAML aliases: %w", err)
}
for _, n := range nodes {
if err := n.DeAnchor(); err != nil {
return nil, fmt.Errorf("inflate YAML aliases: %w", err)
}
}
out, err := kio.StringAll(nodes)
if err != nil {
return nil, fmt.Errorf("inflate YAML aliases: %w", err)
}
return []byte(out), nil
}

func isUnknownAnchorErr(err error) bool {
return err != nil && strings.Contains(err.Error(), "unknown anchor")
}

// wrapDocsAsList nests each YAML document in the stream as an item of a
// synthetic List so anchors/aliases share one document scope.
func wrapDocsAsList(in string) string {
docs := splitYAMLDocuments(in)
var b strings.Builder
b.WriteString("apiVersion: v1\nkind: List\nitems:\n")
for _, doc := range docs {
doc = strings.TrimSpace(doc)
if doc == "" {
continue
}
lines := strings.Split(doc, "\n")
for i, line := range lines {
if i == 0 {
b.WriteString("- ")
} else {
b.WriteString(" ")
}
b.WriteString(line)
b.WriteByte('\n')
}
}
return b.String()
}

func splitYAMLDocuments(in string) []string {
raw := strings.Split(in, "\n")
var (
docs []string
cur strings.Builder
first = true
)
flush := func() {
s := strings.TrimSpace(cur.String())
if s != "" {
docs = append(docs, s)
}
cur.Reset()
}
for _, line := range raw {
if strings.TrimSpace(line) == "---" {
flush()
first = false
continue
}
if !first || cur.Len() > 0 {
// keep going
}
if cur.Len() > 0 {
cur.WriteByte('\n')
}
cur.WriteString(line)
first = false
}
flush()
return docs
}
95 changes: 95 additions & 0 deletions internal/postrender/anchors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
Copyright 2026 The Flux authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package postrender

import (
"bytes"
"testing"

. "github.com/onsi/gomega"
)

// helmV4ListUnwrap mimics Helm v4 annotateAndMerge output: a kind:List with
// in-document anchors is unwrapped into separate docs that still carry the
// original &anchor / *alias markers (invalid cross-document YAML).
const helmV4ListUnwrap = `apiVersion: batch/v1
kind: Job
metadata:
name: example
spec: &jobSpec
template:
spec:
restartPolicy: Never
containers:
- name: main
image: busybox:latest
command: ["true"]
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: example
spec:
schedule: "0 0 * * *"
jobTemplate:
spec: *jobSpec
`

func Test_inflateYAMLAliases_crossDocFromListUnwrap(t *testing.T) {
g := NewWithT(t)

out, err := inflateYAMLAliases([]byte(helmV4ListUnwrap))
g.Expect(err).ToNot(HaveOccurred())
g.Expect(string(out)).ToNot(ContainSubstring("*jobSpec"))
g.Expect(string(out)).ToNot(ContainSubstring("&jobSpec"))
g.Expect(string(out)).To(ContainSubstring("kind: Job"))
g.Expect(string(out)).To(ContainSubstring("kind: CronJob"))
// Alias inlined into the CronJob.
g.Expect(string(out)).To(ContainSubstring("restartPolicy: Never"))
}

func Test_inflateYAMLAliases_unchangedWhenClean(t *testing.T) {
g := NewWithT(t)

in := []byte(mixedResourceMock)
out, err := inflateYAMLAliases(in)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(out).To(Equal(in))
}

func Test_OriginLabels_Run_crossDocAnchorsFromListUnwrap(t *testing.T) {
g := NewWithT(t)

// Combined is what BuildPostRenderers returns; it inflates aliases first.
c := NewCombined(NewOriginLabels("helm.toolkit.fluxcd.io", "namespace", "name"))
got, err := c.Run(bytes.NewBufferString(helmV4ListUnwrap))
g.Expect(err).ToNot(HaveOccurred())
g.Expect(got.String()).To(ContainSubstring("helm.toolkit.fluxcd.io/name: name"))
g.Expect(got.String()).To(ContainSubstring("kind: CronJob"))
g.Expect(got.String()).ToNot(ContainSubstring("*jobSpec"))
}

func Test_OriginLabels_Run_crossDocAnchorsWithoutCombined(t *testing.T) {
g := NewWithT(t)

// Direct OriginLabels still fails on the raw Helm v4 unwrap (documents the
// root cause). Combined must be used for the recovery path.
k := NewOriginLabels("helm.toolkit.fluxcd.io", "namespace", "name")
_, err := k.Run(bytes.NewBufferString(helmV4ListUnwrap))
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring("unknown anchor"))
}
8 changes: 7 additions & 1 deletion internal/postrender/combined.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,13 @@ func NewCombined(renderer ...helmpostrender.PostRenderer) *Combined {
}

func (c *Combined) Run(renderedManifests *bytes.Buffer) (modifiedManifests *bytes.Buffer, err error) {
var result = renderedManifests
// Inflate aliases before any renderer parses the stream. Helm v4 can emit
// cross-document anchors when unwrapping kind:List (see inflateYAMLAliases).
inflated, err := inflateYAMLAliases(renderedManifests.Bytes())
if err != nil {
return nil, err
}
var result = bytes.NewBuffer(inflated)
for _, renderer := range c.renderers {
result, err = renderer.Run(result)
if err != nil {
Expand Down