diff --git a/pkg/action/action.go b/pkg/action/action.go index e93d6181f..bc2dc3049 100644 --- a/pkg/action/action.go +++ b/pkg/action/action.go @@ -173,6 +173,19 @@ const ( filenameAnnotation = "postrenderer.helm.sh/postrender-filename" ) +// clearFlowStyle recursively clears the flow-style formatting flag from a YAML +// node and its descendants, forcing block-style output when the node is later +// serialized. +func clearFlowStyle(node *kyaml.Node) { + if node == nil { + return + } + node.Style &^= kyaml.FlowStyle + for _, child := range node.Content { + clearFlowStyle(child) + } +} + // annotateAndMerge combines multiple YAML files into a single stream of documents, // adding filename annotations to each document for later reconstruction. func annotateAndMerge(files map[string]string) (string, error) { @@ -212,6 +225,15 @@ func annotateAndMerge(files map[string]string) (string, error) { if err := manifest.PipeE(kyaml.SetAnnotation(filenameAnnotation, fname)); err != nil { return "", fmt.Errorf("annotating %s: %w", fname, err) } + // kyaml preserves the flow style of documents that were + // originally written as JSON. Left as-is, the annotated + // document can be re-emitted starting with '{' while + // containing YAML-only syntax (unquoted keys, single-quoted + // strings), which k8s.io/apimachinery's YAML decoder + // misidentifies as pure JSON and fails to parse. Clearing + // the flow style forces block-style output, which is always + // unambiguous YAML. + clearFlowStyle(manifest.YNode()) combinedManifests = append(combinedManifests, manifest) } } diff --git a/pkg/action/action_test.go b/pkg/action/action_test.go index 056c539a5..82f1d6cf3 100644 --- a/pkg/action/action_test.go +++ b/pkg/action/action_test.go @@ -28,6 +28,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + kyamlserializer "k8s.io/apimachinery/pkg/runtime/serializer/yaml" fakeclientset "k8s.io/client-go/kubernetes/fake" "helm.sh/helm/v4/internal/logging" @@ -425,6 +427,26 @@ metadata: postrenderer.helm.sh/postrender-filename: 'templates/configmap.yaml' data: key: value +`, + }, + { + name: "single file with JSON manifest", + files: map[string]string{ + "templates/cm.json": `{"apiVersion": "v1", "kind": "ConfigMap", "metadata": {"name": "test"}, "data": {"key": "hello"}}`, + }, + // The merged output must not be re-emitted in flow style: a + // document annotated in flow style can start with '{' while + // containing YAML-only syntax (unquoted keys, single-quoted + // values), which k8s.io/apimachinery's decoder misidentifies as + // pure JSON and fails to parse. + expected: `"apiVersion": "v1" +"kind": "ConfigMap" +"metadata": + "name": "test" + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.json' +"data": + "key": "hello" `, }, { @@ -1799,6 +1821,34 @@ data: } } +// TestAnnotateAndMerge_JSONManifest_DecodableByApimachinery reproduces +// https://github.com/helm/helm/issues/32668: a chart resource written as pure +// JSON must still be decodable by k8s.io/apimachinery's YAML serializer after +// annotateAndMerge adds the post-render filename annotation. Before the fix, +// kyaml preserved the document's flow style, so the annotated document was +// re-emitted starting with '{' while containing YAML-only syntax (an +// unquoted annotation key and single-quoted value); apimachinery's decoder +// assumes anything starting with '{' is pure JSON and failed to parse it. +func TestAnnotateAndMerge_JSONManifest_DecodableByApimachinery(t *testing.T) { + files := map[string]string{ + "templates/cm.json": `{"apiVersion": "v1", "kind": "ConfigMap", "metadata": {"name": "test"}, "data": {"key": "hello"}}`, + } + + merged, err := annotateAndMerge(files) + require.NoError(t, err) + require.False(t, strings.HasPrefix(strings.TrimSpace(merged), "{"), + "merged output must not start with '{', or apimachinery's decoder will misidentify it as pure JSON: %s", merged) + + deserializer := kyamlserializer.NewDecodingSerializer(unstructured.UnstructuredJSONScheme) + for doc := range strings.SplitSeq(merged, "---") { + if strings.TrimSpace(doc) == "" { + continue + } + _, _, err := deserializer.Decode([]byte(doc), nil, new(unstructured.Unstructured)) + require.NoError(t, err) + } +} + func TestRenderResources_PostRenderer_Success(t *testing.T) { cfg := actionConfigFixture(t)