diff --git a/pkg/plugin-conformance-tests/flatten_value_test.go b/pkg/plugin-conformance-tests/flatten_value_test.go new file mode 100644 index 000000000..0eebf8d85 --- /dev/null +++ b/pkg/plugin-conformance-tests/flatten_value_test.go @@ -0,0 +1,66 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package conformance + +import ( + "encoding/json" + "testing" +) + +func TestFlattenFormaeValueWalk(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "scalar value wrapped at top level", + in: `{"version":{"$value":"1.34","$strategy":"SetOnce","$visibility":"Clear"}}`, + want: `{"version":"1.34"}`, + }, + { + name: "nested wrapper inside an array", + in: `{"items":[{"$value":1},{"$value":2}]}`, + want: `{"items":[1,2]}`, + }, + { + name: "wrapper carrying a nested object as its $value payload", + in: `{"cfg":{"$value":{"a":1,"b":[{"$value":"x"}]}}}`, + want: `{"cfg":{"a":1,"b":["x"]}}`, + }, + { + name: "$res markers are left intact", + in: `{"kubeId":{"$res":true,"$label":"c","$type":"OVH::Kube::Cluster","$property":"id"}}`, + want: `{"kubeId":{"$res":true,"$label":"c","$type":"OVH::Kube::Cluster","$property":"id"}}`, + }, + { + name: "plain scalars and maps without $value untouched", + in: `{"name":"abc","tags":{"env":"prod"}}`, + want: `{"name":"abc","tags":{"env":"prod"}}`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var in any + if err := json.Unmarshal([]byte(tc.in), &in); err != nil { + t.Fatalf("unmarshal in: %v", err) + } + got, err := json.Marshal(flattenFormaeValueWalk(in)) + if err != nil { + t.Fatalf("marshal got: %v", err) + } + // Normalise want through encode/decode so key ordering is stable. + var w any + if err := json.Unmarshal([]byte(tc.want), &w); err != nil { + t.Fatalf("unmarshal want: %v", err) + } + normalised, _ := json.Marshal(w) + if string(got) != string(normalised) { + t.Errorf("flatten:\n got %s\n want %s", got, normalised) + } + }) + } +} diff --git a/pkg/plugin-conformance-tests/harness.go b/pkg/plugin-conformance-tests/harness.go index 66814c354..5c5855ebe 100644 --- a/pkg/plugin-conformance-tests/harness.go +++ b/pkg/plugin-conformance-tests/harness.go @@ -1256,6 +1256,16 @@ func (h *TestHarness) CreateAllUnmanagedResources(evaluatedJSON string) ([]Creat } res.Properties = resolvedProps + // Unwrap any remaining formae.Value wrappers (`{ "$value": ..., "$strategy": ... }`) + // to the bare scalar — the apply path does this in resolver.ConvertToPluginFormat, + // and without it cloud APIs typed against the inner scalar (e.g. OVH's + // cloud.kube.VersionEnum) reject the wrapper as an InvalidRequest. + flattenedProps, err := h.flattenFormaeValuesInProperties(res.Properties) + if err != nil { + return createdResources, fmt.Errorf("failed to flatten formae.Value wrappers for %s: %w", res.Label, err) + } + res.Properties = flattenedProps + // Strip nested empty collections ({}/[]) that PKL renders for unset // nullable Listing/Mapping fields. Without this, K8S rejects resources // with empty probe objects (e.g. livenessProbe: {}). @@ -1466,6 +1476,69 @@ func (h *TestHarness) resolveResolvablesInProperties(properties json.RawMessage, return json.RawMessage(propsStr), nil } +// flattenFormaeValuesInProperties unwraps `formae.Value` PKL objects (which +// serialise to {"$value": ..., "$strategy": ..., "$visibility": ...}) into +// their scalar payload. The agent's resolver.ConvertToPluginFormat does this +// in the apply path so plugins only ever see the resolved value; without an +// equivalent step here, the discovery harness's CreateUnmanagedResource +// forwards the wrapper object verbatim to the plugin and the plugin then +// forwards it to the cloud API, which rejects it as a type-mismatch (e.g. +// "Given data is not valid for type cloud.kube.VersionEnum" on OVH). +// +// Walks the JSON recursively and replaces every object that carries a +// "$value" key with that key's payload. Other resolvable objects ($res +// markers) are left intact — they are handled separately by +// resolveResolvablesInProperties before this runs. +func (h *TestHarness) flattenFormaeValuesInProperties(properties json.RawMessage) (json.RawMessage, error) { + if len(properties) == 0 { + return properties, nil + } + var root any + if err := json.Unmarshal(properties, &root); err != nil { + return properties, fmt.Errorf("failed to unmarshal properties for value flattening: %w", err) + } + flattened := flattenFormaeValueWalk(root) + out, err := json.Marshal(flattened) + if err != nil { + return properties, fmt.Errorf("failed to marshal flattened properties: %w", err) + } + return out, nil +} + +// flattenFormaeValueWalk is the recursive worker for flattenFormaeValuesInProperties. +func flattenFormaeValueWalk(v any) any { + switch val := v.(type) { + case map[string]any: + // A formae.Value carries "$value" and never sets "$res" (the latter + // marks resolvable references, which are resolved separately). If the + // object also carries "$res": true, leave it alone — the resolver pass + // has already touched it (or chosen not to). + if res, isRes := val["$res"].(bool); isRes && res { + out := make(map[string]any, len(val)) + for k, child := range val { + out[k] = flattenFormaeValueWalk(child) + } + return out + } + if inner, has := val["$value"]; has { + return flattenFormaeValueWalk(inner) + } + out := make(map[string]any, len(val)) + for k, child := range val { + out[k] = flattenFormaeValueWalk(child) + } + return out + case []any: + out := make([]any, len(val)) + for i, child := range val { + out[i] = flattenFormaeValueWalk(child) + } + return out + default: + return v + } +} + // findResolvablesRecursive recursively finds all resolvable objects in a JSON structure func (h *TestHarness) findResolvablesRecursive(basePath string, value gjson.Result, resolvables *[]resolvablePath) { if value.IsObject() {