Skip to content

Commit e734404

Browse files
committed
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 b2593d3 commit e734404

3 files changed

Lines changed: 149 additions & 0 deletions

File tree

internal/helmdeployer/install_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"os"
1717

1818
"github.com/rancher/fleet/internal/experimental"
19+
"github.com/rancher/fleet/internal/manifest"
1920
corev1 "k8s.io/api/core/v1"
2021
apierrors "k8s.io/apimachinery/pkg/api/errors"
2122
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -751,3 +752,36 @@ func TestInstallActionCorrectDriftForce(t *testing.T) {
751752
})
752753
}
753754
}
755+
756+
// TestTemplate_ToJsonFlowStyle is a regression test for the Helm v4 + kyaml
757+
// issue where JSON output from a toJson template function is round-tripped
758+
// through kyaml (annotateAndMerge), which converts it into YAML flow-style
759+
// with unquoted keys, e.g. {apiVersion: v1, kind: ConfigMap}.
760+
// k8s.io/apimachinery then mistakes the leading '{' for JSON and passes the
761+
// document through unchanged, causing json.Unmarshal to fail.
762+
// See the Fleet issue tracker (issue.txt) for the user-visible error:
763+
//
764+
// error while running post render on files: invalid character 'a'
765+
func TestTemplate_ToJsonFlowStyle(t *testing.T) {
766+
m := &manifest.Manifest{
767+
Resources: []fleet.BundleResource{
768+
{
769+
Name: "test-chart/Chart.yaml",
770+
Content: "apiVersion: v2\nname: test-chart\nversion: 0.1.0\ntype: application\n",
771+
},
772+
{
773+
// Template that outputs a raw JSON document via toJson.
774+
// Helm v4's kyaml converts this to flow-style YAML with unquoted
775+
// keys before Fleet's post-renderer sees it.
776+
Name: "test-chart/templates/configmap.yaml",
777+
Content: "{{- toJson (dict \"apiVersion\" \"v1\" \"kind\" \"ConfigMap\" \"metadata\" (dict \"name\" \"json-output\") \"data\" (dict \"key\" \"value\")) }}\n",
778+
},
779+
},
780+
}
781+
opts := fleet.BundleDeploymentOptions{
782+
DefaultNamespace: "default",
783+
Helm: &fleet.HelmOptions{Chart: "test-chart"},
784+
}
785+
_, err := Template(context.Background(), "test-bundle", m, opts, "v1.25.0")
786+
require.NoError(t, err)
787+
}

internal/helmdeployer/postrender.go

Lines changed: 84 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,85 @@ 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+
// normalizeFlowStyleDocs converts YAML documents that start with '{' but are
32+
// not valid JSON (i.e. YAML flow-style with unquoted keys, as emitted by
33+
// Helm v4's kyaml when templates use the toJson function) into block-style
34+
// YAML. k8s.io/apimachinery's ToJSON treats any '{'-prefixed document as
35+
// already-valid JSON and returns it unchanged, so a flow-style document with
36+
// unquoted keys causes json.Unmarshal to fail with "invalid character".
37+
//
38+
// If no document boundary in data starts with '{', data is returned unchanged
39+
// with no allocations.
40+
func normalizeFlowStyleDocs(data []byte) ([]byte, error) {
41+
if !hasFlowStyleCandidate(data) {
42+
return data, nil
43+
}
44+
reader := utilyaml.NewYAMLReader(bufio.NewReader(bytes.NewReader(data)))
45+
var out bytes.Buffer
46+
for {
47+
doc, err := reader.Read()
48+
if err != nil {
49+
if errors.Is(err, io.EOF) {
50+
break
51+
}
52+
return nil, err
53+
}
54+
trimmed := bytes.TrimSpace(doc)
55+
if len(trimmed) == 0 {
56+
continue
57+
}
58+
if trimmed[0] == '{' && !json.Valid(trimmed) {
59+
var obj any
60+
if err := sigsyaml.Unmarshal(trimmed, &obj); err != nil {
61+
return nil, err
62+
}
63+
doc, err = sigsyaml.Marshal(obj)
64+
if err != nil {
65+
return nil, err
66+
}
67+
}
68+
out.WriteString("---\n")
69+
out.Write(doc)
70+
}
71+
return out.Bytes(), nil
72+
}
73+
74+
// hasFlowStyleCandidate reports whether data contains at least one YAML
75+
// document whose first non-whitespace byte is '{'. It is a fast, zero-
76+
// allocation scan used as a pre-check before the more expensive processing
77+
// in normalizeFlowStyleDocs.
78+
func hasFlowStyleCandidate(data []byte) bool {
79+
firstNonSpace := func(b []byte) byte {
80+
for _, c := range b {
81+
if c != ' ' && c != '\t' && c != '\r' && c != '\n' {
82+
return c
83+
}
84+
}
85+
return 0
86+
}
87+
if firstNonSpace(data) == '{' {
88+
return true
89+
}
90+
sep := []byte("\n---")
91+
rest := data
92+
for {
93+
i := bytes.Index(rest, sep)
94+
if i < 0 {
95+
return false
96+
}
97+
rest = rest[i+len(sep):]
98+
if firstNonSpace(rest) == '{' {
99+
return true
100+
}
101+
}
102+
}
103+
25104
type postRender struct {
26105
labelPrefix string
27106
labelSuffix string
@@ -35,6 +114,11 @@ type postRender struct {
35114
func (p *postRender) Run(renderedManifests *bytes.Buffer) (modifiedManifests *bytes.Buffer, err error) {
36115
data := renderedManifests.Bytes()
37116

117+
data, err = normalizeFlowStyleDocs(data)
118+
if err != nil {
119+
return nil, err
120+
}
121+
38122
objs, err := yaml.ToObjects(bytes.NewBuffer(data))
39123
if err != nil {
40124
return nil, err

internal/helmdeployer/postrender_test.go

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

179179
}
180+
181+
func TestPostRenderer_Run_FlowStyleYAML(t *testing.T) {
182+
// Helm v4's kyaml converts JSON output from toJson template functions into
183+
// YAML flow-style with unquoted keys, e.g. {apiVersion: v1, kind: ConfigMap}.
184+
// k8s.io/apimachinery ToJSON sees the leading '{' and treats it as JSON,
185+
// returning it unchanged; json.Unmarshal then fails on the unquoted keys.
186+
flowStyleDoc := `{apiVersion: v1, kind: ConfigMap, metadata: {name: test-cm}, data: {key: value}}`
187+
188+
pr := postRender{
189+
manifest: &manifest.Manifest{Resources: []v1alpha1.BundleResource{}},
190+
chart: &chartv2.Chart{},
191+
opts: v1alpha1.BundleDeploymentOptions{},
192+
}
193+
194+
out, err := pr.Run(bytes.NewBufferString(flowStyleDoc))
195+
if err != nil {
196+
t.Fatalf("unexpected error: %v", err)
197+
}
198+
199+
objs, err := yaml.ToObjects(bytes.NewBuffer(out.Bytes()))
200+
if err != nil {
201+
t.Fatalf("unexpected error parsing output: %v", err)
202+
}
203+
if len(objs) != 1 {
204+
t.Fatalf("expected 1 object, got %d", len(objs))
205+
}
206+
gvk := objs[0].GetObjectKind().GroupVersionKind()
207+
if gvk.Kind != "ConfigMap" {
208+
t.Errorf("expected ConfigMap, got %s", gvk.Kind)
209+
}
210+
}

0 commit comments

Comments
 (0)