Skip to content

Commit eda00d9

Browse files
authored
Normalize YAML flow-style docs in post-renderer [SURE-11642] (#5180) (#5223)
* Normalize YAML flow-style docs in post-renderer Helm v4's kyaml round-trips rendered manifests through its YAML parser, converting JSON output from toJson template functions into YAML flow-style with unquoted keys, e.g. {apiVersion: v1, kind: ConfigMap, ...}. k8s.io/apimachinery's IsJSONBuffer detects the leading '{' and returns such documents unchanged assuming they are already valid JSON. Passing them to json.Unmarshal then fails with 'invalid character' because the keys are unquoted. Add normalizeFlowStyleDocs to the post-renderer: before handing the rendered buffer to yaml.ToObjects, each document starting with '{' is checked with json.Valid; documents that fail are round-tripped through sigs.k8s.io/yaml to produce block-style YAML that the existing decode path handles correctly. Add hasFlowStyleCandidate as a zero-allocation fast-path: on every render call, a raw byte scan checks whether any document boundary starts with '{'. When none does (the common case), normalizeFlowStyleDocs returns the original slice immediately without any allocation or buffer rebuild.
1 parent 3b896d9 commit eda00d9

3 files changed

Lines changed: 272 additions & 0 deletions

File tree

internal/helmdeployer/install_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import (
2020
apierrors "k8s.io/apimachinery/pkg/api/errors"
2121
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2222
kubernetesfake "k8s.io/client-go/kubernetes/fake"
23+
24+
"github.com/rancher/fleet/internal/manifest"
2325
)
2426

2527
func TestValuesFrom(t *testing.T) {
@@ -754,3 +756,36 @@ func TestInstallActionCorrectDriftForce(t *testing.T) {
754756
})
755757
}
756758
}
759+
760+
// TestTemplate_ToJsonFlowStyle is a regression test for the Helm v4 + kyaml
761+
// issue where JSON output from a toJson template function is round-tripped
762+
// through kyaml (annotateAndMerge), which converts it into YAML flow-style
763+
// with unquoted keys, e.g. {apiVersion: v1, kind: ConfigMap}.
764+
// k8s.io/apimachinery then mistakes the leading '{' for JSON and passes the
765+
// document through unchanged, causing json.Unmarshal to fail.
766+
// The user-visible error looks like:
767+
//
768+
// error while running post render on files: invalid character 'a'
769+
func TestTemplate_ToJsonFlowStyle(t *testing.T) {
770+
m := &manifest.Manifest{
771+
Resources: []fleet.BundleResource{
772+
{
773+
Name: "test-chart/Chart.yaml",
774+
Content: "apiVersion: v2\nname: test-chart\nversion: 0.1.0\ntype: application\n",
775+
},
776+
{
777+
// Template that outputs a raw JSON document via toJson.
778+
// Helm v4's kyaml converts this to flow-style YAML with unquoted
779+
// keys before Fleet's post-renderer sees it.
780+
Name: "test-chart/templates/configmap.yaml",
781+
Content: "{{- toJson (dict \"apiVersion\" \"v1\" \"kind\" \"ConfigMap\" \"metadata\" (dict \"name\" \"json-output\") \"data\" (dict \"key\" \"value\")) }}\n",
782+
},
783+
},
784+
}
785+
opts := fleet.BundleDeploymentOptions{
786+
DefaultNamespace: "default",
787+
Helm: &fleet.HelmOptions{Chart: "test-chart"},
788+
}
789+
_, err := Template(context.Background(), "test-bundle", m, opts, "v1.25.0")
790+
require.NoError(t, err)
791+
}

internal/helmdeployer/postrender.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
package helmdeployer
22

33
import (
4+
"bufio"
45
"bytes"
6+
"encoding/json"
7+
"errors"
58
"fmt"
9+
"io"
610
"strings"
711

812
"helm.sh/helm/v4/pkg/kube"
@@ -18,10 +22,122 @@ import (
1822
"github.com/rancher/wrangler/v3/pkg/yaml"
1923

2024
"k8s.io/apimachinery/pkg/api/meta"
25+
utilyaml "k8s.io/apimachinery/pkg/util/yaml"
26+
sigsyaml "sigs.k8s.io/yaml"
2127
)
2228

2329
const CRDKind = "CustomResourceDefinition"
2430

31+
var (
32+
yamlDocMarker = []byte("---")
33+
newlineDocMarker = []byte("\n---")
34+
)
35+
36+
// isYAMLDocMarker reports whether b starts with a valid YAML document-start
37+
// marker: "---" followed by whitespace, CR, LF, or end of input. Per the
38+
// YAML spec, "---" is only a marker when it is the complete token on a line;
39+
// a sequence like "---foo" is NOT a marker and must not be stripped.
40+
func isYAMLDocMarker(b []byte) bool {
41+
if !bytes.HasPrefix(b, yamlDocMarker) {
42+
return false
43+
}
44+
if len(b) == len(yamlDocMarker) {
45+
return true
46+
}
47+
c := b[len(yamlDocMarker)]
48+
return c == ' ' || c == '\t' || c == '\r' || c == '\n'
49+
}
50+
51+
// normalizeFlowStyleDocs converts YAML documents that start with '{' but are
52+
// not valid JSON (i.e. YAML flow-style with unquoted keys, as emitted by
53+
// Helm v4's kyaml when templates use the toJson function) into block-style
54+
// YAML. k8s.io/apimachinery's ToJSON treats any '{'-prefixed document as
55+
// already-valid JSON and returns it unchanged, so a flow-style document with
56+
// unquoted keys causes json.Unmarshal to fail with "invalid character".
57+
//
58+
// If no document boundary in data starts with '{', data is returned unchanged
59+
// with no allocations. When flow-style documents are present, all documents
60+
// are re-serialized into a consistent "---\n<content>\n" format; documents
61+
// that are not flow-style YAML are passed through without content changes.
62+
func normalizeFlowStyleDocs(data []byte) ([]byte, error) {
63+
if !hasFlowStyleCandidate(data) {
64+
return data, nil
65+
}
66+
reader := utilyaml.NewYAMLReader(bufio.NewReader(bytes.NewReader(data)))
67+
var out bytes.Buffer
68+
for {
69+
doc, err := reader.Read()
70+
if err != nil {
71+
if errors.Is(err, io.EOF) {
72+
break
73+
}
74+
return nil, err
75+
}
76+
// The YAMLReader may include the document-start marker ("---") as part
77+
// of the document bytes when it appears at the beginning of the input
78+
// stream. Strip it to obtain the raw content of the document. Only
79+
// strip a marker that is valid per YAML (followed by whitespace/EOF).
80+
content := bytes.TrimSpace(doc)
81+
if isYAMLDocMarker(content) {
82+
content = bytes.TrimLeft(content[len(yamlDocMarker):], " \t\r\n")
83+
}
84+
if len(content) == 0 {
85+
continue
86+
}
87+
if content[0] == '{' && !json.Valid(content) {
88+
var obj any
89+
if err := sigsyaml.Unmarshal(content, &obj); err != nil {
90+
return nil, err
91+
}
92+
content, err = sigsyaml.Marshal(obj)
93+
if err != nil {
94+
return nil, err
95+
}
96+
}
97+
out.WriteString("---\n")
98+
out.Write(content)
99+
if content[len(content)-1] != '\n' {
100+
out.WriteByte('\n')
101+
}
102+
}
103+
return out.Bytes(), nil
104+
}
105+
106+
// hasFlowStyleCandidate reports whether data contains at least one YAML
107+
// document whose first non-whitespace byte is '{'. It is a fast, zero-
108+
// allocation scan used as a pre-check before the more expensive processing
109+
// in normalizeFlowStyleDocs.
110+
func hasFlowStyleCandidate(data []byte) bool {
111+
firstNonSpace := func(b []byte) byte {
112+
for _, c := range b {
113+
if c != ' ' && c != '\t' && c != '\r' && c != '\n' {
114+
return c
115+
}
116+
}
117+
return 0
118+
}
119+
rest := data
120+
// When data begins with a valid YAML document-start marker ("---" followed
121+
// by whitespace), skip past it so the content of the first document is
122+
// checked rather than the marker itself. CRLF before the marker is handled
123+
// by TrimLeft.
124+
if trimmed := bytes.TrimLeft(data, " \t\r\n"); isYAMLDocMarker(trimmed) {
125+
rest = trimmed[len(yamlDocMarker):]
126+
}
127+
for {
128+
if firstNonSpace(rest) == '{' {
129+
return true
130+
}
131+
// Searching for "\n---" also covers CRLF separators ("\r\n---") because
132+
// "\n---" is a substring of "\r\n---".
133+
i := bytes.Index(rest, newlineDocMarker)
134+
if i < 0 {
135+
return false
136+
}
137+
rest = rest[i+len(newlineDocMarker):]
138+
}
139+
}
140+
25141
type postRender struct {
26142
labelPrefix string
27143
labelSuffix string
@@ -35,6 +151,11 @@ type postRender struct {
35151
func (p *postRender) Run(renderedManifests *bytes.Buffer) (modifiedManifests *bytes.Buffer, err error) {
36152
data := renderedManifests.Bytes()
37153

154+
data, err = normalizeFlowStyleDocs(data)
155+
if err != nil {
156+
return nil, err
157+
}
158+
38159
objs, err := yaml.ToObjects(bytes.NewBuffer(data))
39160
if err != nil {
40161
return nil, err

internal/helmdeployer/postrender_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,119 @@ func TestPostRenderer_Run_DeleteCRDs(t *testing.T) {
177177
})
178178

179179
}
180+
181+
func TestHasFlowStyleCandidate(t *testing.T) {
182+
tests := map[string]struct {
183+
input string
184+
want bool
185+
}{
186+
"empty": {input: "", want: false},
187+
"block-style only": {
188+
input: "apiVersion: v1\nkind: ConfigMap\n",
189+
want: false,
190+
},
191+
"flow-style, no marker": {
192+
// Document starting directly with '{' (no leading "---").
193+
input: "{apiVersion: v1, kind: ConfigMap}",
194+
want: true,
195+
},
196+
"flow-style after LF marker": {
197+
// Helm commonly prefixes with "---\n".
198+
input: "---\n{apiVersion: v1, kind: ConfigMap}",
199+
want: true,
200+
},
201+
"flow-style after CRLF marker": {
202+
// Windows-style line endings before and after the separator.
203+
input: "---\r\n{apiVersion: v1, kind: ConfigMap}",
204+
want: true,
205+
},
206+
"flow-style in second doc, LF separator": {
207+
input: "apiVersion: v1\n---\n{apiVersion: v2, kind: ConfigMap}",
208+
want: true,
209+
},
210+
"flow-style in second doc, CRLF separator": {
211+
// "\n---" is a substring of "\r\n---", so CRLF is handled.
212+
input: "apiVersion: v1\r\n---\r\n{apiVersion: v2, kind: ConfigMap}",
213+
want: true,
214+
},
215+
"triple-dash not a marker (no trailing whitespace)": {
216+
// "---foo" is NOT a document-start marker per the YAML spec.
217+
input: "---foo: bar\n",
218+
want: false,
219+
},
220+
}
221+
for name, tc := range tests {
222+
t.Run(name, func(t *testing.T) {
223+
if got := hasFlowStyleCandidate([]byte(tc.input)); got != tc.want {
224+
t.Errorf("hasFlowStyleCandidate(%q) = %v, want %v", tc.input, got, tc.want)
225+
}
226+
})
227+
}
228+
}
229+
230+
func TestPostRenderer_Run_FlowStyleYAML(t *testing.T) {
231+
// Helm v4's kyaml converts JSON output from toJson template functions into
232+
// YAML flow-style with unquoted keys, e.g. {apiVersion: v1, kind: ConfigMap}.
233+
// k8s.io/apimachinery ToJSON sees the leading '{' and treats it as JSON,
234+
// returning it unchanged; json.Unmarshal then fails on the unquoted keys.
235+
const flowDoc = "{apiVersion: v1, kind: ConfigMap, metadata: {name: test-cm}, data: {key: value}}"
236+
tests := map[string]struct {
237+
input string
238+
}{
239+
"flow-style with LF document-start marker": {
240+
// Helm render output commonly starts with "---\n".
241+
input: "---\n" + flowDoc,
242+
},
243+
"flow-style with CRLF document-start marker": {
244+
input: "---\r\n" + flowDoc,
245+
},
246+
"flow-style with no document-start marker": {
247+
input: flowDoc,
248+
},
249+
"block-style followed by flow-style": {
250+
input: "apiVersion: v1\nkind: Namespace\nmetadata:\n name: other\n---\n" + flowDoc,
251+
},
252+
"flow-style followed by block-style": {
253+
input: "---\n" + flowDoc + "\n---\napiVersion: v1\nkind: Namespace\nmetadata:\n name: other\n",
254+
},
255+
}
256+
257+
for name, tc := range tests {
258+
t.Run(name, func(t *testing.T) {
259+
pr := postRender{
260+
manifest: &manifest.Manifest{Resources: []v1alpha1.BundleResource{}},
261+
chart: &chartv2.Chart{},
262+
opts: v1alpha1.BundleDeploymentOptions{},
263+
}
264+
265+
out, err := pr.Run(bytes.NewBufferString(tc.input))
266+
if err != nil {
267+
t.Fatalf("unexpected error: %v", err)
268+
}
269+
270+
objs, err := yaml.ToObjects(bytes.NewBuffer(out.Bytes()))
271+
if err != nil {
272+
t.Fatalf("unexpected error parsing output: %v", err)
273+
}
274+
// Every case should produce at least the ConfigMap from the flow-style doc.
275+
found := false
276+
for _, obj := range objs {
277+
if obj.GetObjectKind().GroupVersionKind().Kind == "ConfigMap" {
278+
found = true
279+
break
280+
}
281+
}
282+
if !found {
283+
t.Errorf("expected a ConfigMap in output, got kinds: %v", objectKinds(objs))
284+
}
285+
})
286+
}
287+
}
288+
289+
func objectKinds(objs []kruntime.Object) []string {
290+
kinds := make([]string, 0, len(objs))
291+
for _, o := range objs {
292+
kinds = append(kinds, o.GetObjectKind().GroupVersionKind().Kind)
293+
}
294+
return kinds
295+
}

0 commit comments

Comments
 (0)