Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions pkg/plugin-conformance-tests/flatten_value_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
73 changes: 73 additions & 0 deletions pkg/plugin-conformance-tests/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}).
Expand Down Expand Up @@ -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() {
Expand Down
Loading