From 79ac0647ff23314e180ae6c2621479ff6b4cd5fb Mon Sep 17 00:00:00 2001 From: zanarelli Date: Sun, 2 Aug 2026 07:55:12 -0300 Subject: [PATCH] Fix post-render failure on YAML anchors from List unwrap Helm v4 annotateAndMerge unwraps kind:List into separate documents while preserving &anchor/*alias markers. That creates cross-document aliases which OriginLabels (always on) rejects as unknown anchors. Inflate aliases in Combined.Run by re-wrapping into a List, DeAnchor, and re-serializing only when ParseAll fails with an unknown-anchor error. Signed-off-by: zanarelli --- internal/postrender/anchors.go | 125 ++++++++++++++++++++++++++++ internal/postrender/anchors_test.go | 95 +++++++++++++++++++++ internal/postrender/combined.go | 8 +- 3 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 internal/postrender/anchors.go create mode 100644 internal/postrender/anchors_test.go diff --git a/internal/postrender/anchors.go b/internal/postrender/anchors.go new file mode 100644 index 000000000..c80f7214e --- /dev/null +++ b/internal/postrender/anchors.go @@ -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 +} diff --git a/internal/postrender/anchors_test.go b/internal/postrender/anchors_test.go new file mode 100644 index 000000000..400194060 --- /dev/null +++ b/internal/postrender/anchors_test.go @@ -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")) +} diff --git a/internal/postrender/combined.go b/internal/postrender/combined.go index 6de4506c8..bdf14519e 100644 --- a/internal/postrender/combined.go +++ b/internal/postrender/combined.go @@ -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 {