diff --git a/resourcex/README.md b/resourcex/README.md index 47d042a1..32830b33 100644 --- a/resourcex/README.md +++ b/resourcex/README.md @@ -4,21 +4,18 @@ The `resourcex` package extends the `github.com/pulumi/pulumi/sdk/v3/go/common/r for working with property values. 1. `Extract` - Extract structured values from a property map, with tracking of unknownness and secretness. -2. `DecodeValues` - Decode a property map into a JSON-like structure containing only values. +2. `Decode` - Decode a property map into a JSON-like structure containing only values. 3. `Traverse` - Traverse a property path, visiting each property value. ## Extraction The `Extract` function is designed to extract subsets of values from property map using structs. -Information about the unknownness and secretness of the extracted values is provided, e.g. to annotate output properties. +Information about the unknownness and secretness of the extracted values is provided, e.g. to annotate output properties. -Here's a real-world example: +Here's an example of decoding a property map into various structures, observing how unknownness and secretness varies: ```go - -func Test_Extract_Example(t *testing.T) { - - res1 := resource.URN("urn:pulumi:test::test::kubernetes:core/v1:Namespace::some-namespace") + res1 := resource.URN("urn:pulumi:test::test::kubernetes:core/v1:Namespace::some-namespace") props := resource.PropertyMap{ "chart": resource.NewStringProperty("nginx"), @@ -38,20 +35,20 @@ func Test_Extract_Example(t *testing.T) { Dependencies: []resource.URN{res1}, }), "args": resource.NewArrayProperty([]resource.PropertyValue{ - resource.MakeComputed(resource.NewObjectProperty(resource.PropertyMap{})), resource.NewObjectProperty(resource.PropertyMap{ "name": resource.NewStringProperty("a"), - "value": resource.MakeComputed(resource.NewStringProperty("")), + "value": resource.MakeSecret(resource.NewStringProperty("a")), }), + resource.MakeComputed(resource.NewObjectProperty(resource.PropertyMap{})), resource.NewObjectProperty(resource.PropertyMap{ - "name": resource.NewStringProperty("b"), - "value": resource.MakeSecret(resource.NewStringProperty("b")), + "name": resource.NewStringProperty("c"), + "value": resource.MakeSecret(resource.NewStringProperty("c")), }), }), } type RepositoryOpts struct { - // Repository where to locate the requested chart. If is a URL the chart is installed without installing the repository. + // Repository where to locate the requested chart. Repo string `json:"repo,omitempty"` // The Repositories CA File CAFile string `json:"caFile,omitempty"` @@ -65,11 +62,6 @@ func Test_Extract_Example(t *testing.T) { Username string `json:"username,omitempty"` } - type Arg struct { - Name string `json:"name,omitempty"` - Value string `json:"value,omitempty"` - } - type Loader struct { Chart string `json:"chart,omitempty"` DependencyUpdate *bool `json:"dependencyUpdate,omitempty"` @@ -82,7 +74,6 @@ func Test_Extract_Example(t *testing.T) { result, err := Extract(loader, props, ExtractOptions{RejectUnknowns: false}) assert.NoError(t, err) assert.Equal(t, ExtractResult{ContainsUnknowns: false, ContainsSecrets: true}, result) - t.Logf("\n%+v", result) // EXAMPLE: anonymous struct (version) version := struct { @@ -92,19 +83,18 @@ func Test_Extract_Example(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "1.24.0", version.Version) assert.Equal(t, ExtractResult{ContainsUnknowns: false, ContainsSecrets: false}, result) - t.Logf("\n%+v\n%+v", version, result) - // EXAMPLE: anonymous struct (namespace) + // EXAMPLE: anonymous struct ("namespace") namespace := struct { Namespace string `json:"namespace"` }{} result, err = Extract(&namespace, props, ExtractOptions{RejectUnknowns: false}) assert.NoError(t, err) assert.Equal(t, "", namespace.Namespace) - assert.Equal(t, ExtractResult{ContainsUnknowns: true, ContainsSecrets: true, Dependencies: []resource.URN{res1}}, result) - t.Logf("\n%+v\n%+v", namespace, result) + assert.Equal(t, + ExtractResult{ContainsUnknowns: true, ContainsSecrets: true, Dependencies: []resource.URN{res1}}, result) - // EXAMPLE: not present (dependencyUpdate, optional) + // EXAMPLE: unset property ("dependencyUpdate") dependencyUpdate := struct { DependencyUpdate *bool `json:"dependencyUpdate"` }{} @@ -112,17 +102,141 @@ func Test_Extract_Example(t *testing.T) { assert.NoError(t, err) assert.Nil(t, dependencyUpdate.DependencyUpdate) assert.Equal(t, ExtractResult{ContainsUnknowns: false, ContainsSecrets: false}, result) - t.Logf("\n%+v\n%+v", dependencyUpdate, result) // EXAMPLE: arrays + type Arg struct { + Name string `json:"name"` + Value string `json:"value"` + } args := struct { - Args []Arg `json:"args"` + Args []*Arg `json:"args"` }{} result, err = Extract(&args, props, ExtractOptions{RejectUnknowns: false}) assert.NoError(t, err) - assert.Equal(t, []Arg{{Name: "", Value: ""}, {Name: "a", Value: ""}, {Name: "b", Value: "b"}}, args.Args) + assert.Equal(t, []*Arg{{Name: "a", Value: "a"}, nil, {Name: "c", Value: "c"}}, args.Args) assert.Equal(t, ExtractResult{ContainsUnknowns: true, ContainsSecrets: true}, result) - t.Logf("\n%+v\n%+v", args, result) -} -``` \ No newline at end of file + // EXAMPLE: arrays (names only) + type ArgNames struct { + Name string `json:"name"` + } + argNames := struct { + Args []*ArgNames `json:"args"` + }{} + result, err = Extract(&argNames, props, ExtractOptions{RejectUnknowns: false}) + assert.NoError(t, err) + assert.Equal(t, []*ArgNames{{Name: "a"}, nil, {Name: "c"}}, argNames.Args) + assert.Equal(t, ExtractResult{ContainsUnknowns: true, ContainsSecrets: false}, result) + +``` + +## Decoding + +The `Decode` function decodes a property map into a JSON-like map structure containing pure values. +Unknown and computed values are decoded to `null`, both for objects and for arrays. + +The following property value types are supported: `Bool`, `Number`, `String`, `Array`, `Computed`, +`Output`, `Secret`, `Object`. + +The following property value types are NOT supported: `Asset`, `Archive`, `ResourceReference`. + +Here's an example: + +```go + res1 := resource.URN("urn:pulumi:test::test::kubernetes:core/v1:Namespace::some-namespace") + + props := resource.PropertyMap{ + "chart": resource.NewStringProperty("nginx"), + "version": resource.NewStringProperty("1.24.0"), + "repositoryOpts": resource.NewObjectProperty(resource.PropertyMap{ + "repo": resource.NewStringProperty("https://charts.bitnami.com/bitnami"), + "username": resource.NewStringProperty("username"), + "password": resource.NewSecretProperty(&resource.Secret{ + Element: resource.NewStringProperty("password"), + }), + "other": resource.MakeComputed(resource.NewStringProperty("")), + }), + "namespace": resource.NewOutputProperty(resource.Output{ + Element: resource.NewStringProperty(""), + Known: false, + Secret: true, + Dependencies: []resource.URN{res1}, + }), + "args": resource.NewArrayProperty([]resource.PropertyValue{ + resource.NewObjectProperty(resource.PropertyMap{ + "name": resource.NewStringProperty("a"), + "value": resource.MakeSecret(resource.NewStringProperty("a")), + }), + resource.MakeComputed(resource.NewObjectProperty(resource.PropertyMap{})), + resource.NewObjectProperty(resource.PropertyMap{ + "name": resource.NewStringProperty("c"), + "value": resource.MakeSecret(resource.NewStringProperty("c")), + }), + }), + } + + decoded := Decode(props) + assert.Equal(t, map[string]interface{}{ + "chart": "nginx", + "version": "1.24.0", + "repositoryOpts": map[string]interface{}{ + "repo": "https://charts.bitnami.com/bitnami", + "username": "username", + "password": "password", + "other": nil, + }, + "namespace": nil, + "args": []interface{}{ + map[string]interface{}{ + "name": "a", + "value": "a", + }, + nil, + map[string]interface{}{ + "name": "c", + "value": "c", + }, + }, + }, decoded) +``` + +## Traversal + +The `Traverse` function traverses a property map along the given property path, +invoking a callback function for each property value it encounters, including the map itself. + +A wildcard may be used as an array index to traverse all elements of the array. + +Examples of valid paths: + - root + - root.nested + - root.double.nest + - root.array[0] + - root.array[100] + - root.array[0].nested + - root.array[0][1].nested + - root.nested.array[0].double[1] + - root.array[*] + - root.array[*].field + +For example, given this property map: + +```go + props := /* A */ resource.NewObjectProperty(resource.PropertyMap{ + "chart": resource.NewStringProperty("nginx"), + "version": resource.NewStringProperty("1.24.0"), + "repositoryOpts": /* B */ resource.NewObjectProperty(resource.PropertyMap{ + "repo": resource.NewStringProperty("https://charts.bitnami.com/bitnami"), + "username": resource.NewStringProperty("username"), + "password": /* C */ resource.NewSecretProperty(&resource.Secret{ + Element: /* D */ resource.NewStringProperty("password"), + }), + }), + }) +``` + +Traversing the path `repositoryOpts.password` would invoke the callback function for each of the following values: +1. the root-level property value (A) +2. the "object" property value (B) +3. the "secret" property value (C) +4. the "string" property value (D) diff --git a/resourcex/decode.go b/resourcex/decode.go index 5308ff58..36f22ee8 100644 --- a/resourcex/decode.go +++ b/resourcex/decode.go @@ -19,10 +19,10 @@ import ( "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" ) -// DecodeValues decodes a property map into a JSON-like structure containing only values. +// Decode decodes a property map into a JSON-like structure containing only values. // Unknown values are decoded as nil, both in maps and arrays. // Secrets are collapsed into their underlying values. -func DecodeValues(props resource.PropertyMap) interface{} { +func Decode(props resource.PropertyMap) interface{} { return decodeM(props) } diff --git a/resourcex/decode_test.go b/resourcex/decode_test.go index b44600d6..5ad53070 100644 --- a/resourcex/decode_test.go +++ b/resourcex/decode_test.go @@ -18,10 +18,12 @@ import ( "testing" "github.com/pulumi/pulumi/sdk/v3/go/common/resource" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func Test_Decode(t *testing.T) { + t.Parallel() tests := []struct { name string props resource.PropertyMap @@ -198,9 +200,72 @@ func Test_Decode(t *testing.T) { }, } for _, tt := range tests { + tt := tt t.Run(tt.name, func(t *testing.T) { - actual := DecodeValues(tt.props) + t.Parallel() + actual := Decode(tt.props) require.Equal(t, tt.expected, actual, "expected result") }) } } + +func Test_Decode_Example(t *testing.T) { + t.Parallel() + + res1 := resource.URN("urn:pulumi:test::test::kubernetes:core/v1:Namespace::some-namespace") + + props := resource.PropertyMap{ + "chart": resource.NewStringProperty("nginx"), + "version": resource.NewStringProperty("1.24.0"), + "repositoryOpts": resource.NewObjectProperty(resource.PropertyMap{ + "repo": resource.NewStringProperty("https://charts.bitnami.com/bitnami"), + "username": resource.NewStringProperty("username"), + "password": resource.NewSecretProperty(&resource.Secret{ + Element: resource.NewStringProperty("password"), + }), + "other": resource.MakeComputed(resource.NewStringProperty("")), + }), + "namespace": resource.NewOutputProperty(resource.Output{ + Element: resource.NewStringProperty(""), + Known: false, + Secret: true, + Dependencies: []resource.URN{res1}, + }), + "args": resource.NewArrayProperty([]resource.PropertyValue{ + resource.NewObjectProperty(resource.PropertyMap{ + "name": resource.NewStringProperty("a"), + "value": resource.MakeSecret(resource.NewStringProperty("a")), + }), + resource.MakeComputed(resource.NewObjectProperty(resource.PropertyMap{})), + resource.NewObjectProperty(resource.PropertyMap{ + "name": resource.NewStringProperty("c"), + "value": resource.MakeSecret(resource.NewStringProperty("c")), + }), + }), + } + + decoded := Decode(props) + assert.Equal(t, map[string]interface{}{ + "chart": "nginx", + "version": "1.24.0", + "repositoryOpts": map[string]interface{}{ + "repo": "https://charts.bitnami.com/bitnami", + "username": "username", + "password": "password", + "other": nil, + }, + "namespace": nil, + "args": []interface{}{ + map[string]interface{}{ + "name": "a", + "value": "a", + }, + nil, + map[string]interface{}{ + "name": "c", + "value": "c", + }, + }, + }, decoded) + t.Logf("\n%+v", printJSON(decoded)) +} diff --git a/resourcex/extract.go b/resourcex/extract.go index af8634ab..b2b10676 100644 --- a/resourcex/extract.go +++ b/resourcex/extract.go @@ -46,7 +46,7 @@ func Extract(target interface{}, props resource.PropertyMap, opts ExtractOptions } // decode the property map into a JSON-like structure containing only values. - stripped := DecodeValues(props) + decoded := Decode(props) // deserialize the JSON-like structure into a strongly typed struct. config := &mapstructure.DecoderConfig{ @@ -59,7 +59,7 @@ func Extract(target interface{}, props resource.PropertyMap, opts ExtractOptions if err != nil { return ExtractResult{}, err } - err = decoder.Decode(stripped) + err = decoder.Decode(decoded) if err != nil { return ExtractResult{}, err } diff --git a/resourcex/extract_test.go b/resourcex/extract_test.go index 10775264..cd099513 100644 --- a/resourcex/extract_test.go +++ b/resourcex/extract_test.go @@ -15,6 +15,7 @@ package resourcex import ( + "encoding/json" "testing" "github.com/pulumi/pulumi/sdk/v3/go/common/resource" @@ -23,6 +24,7 @@ import ( ) func Test_Extract(t *testing.T) { + t.Parallel() res1 := resource.URN("urn:pulumi:test::test::kubernetes:core/v1:Namespace::some-namespace") @@ -474,6 +476,7 @@ func Test_Extract(t *testing.T) { for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { + t.Parallel() result, err := Extract(&tt.actual, tt.props, tt.opts) if tt.err != nil { require.Equal(t, tt.err, err, "expected error") @@ -487,6 +490,7 @@ func Test_Extract(t *testing.T) { } func Test_Extract_Example(t *testing.T) { + t.Parallel() res1 := resource.URN("urn:pulumi:test::test::kubernetes:core/v1:Namespace::some-namespace") @@ -508,20 +512,20 @@ func Test_Extract_Example(t *testing.T) { Dependencies: []resource.URN{res1}, }), "args": resource.NewArrayProperty([]resource.PropertyValue{ - resource.MakeComputed(resource.NewObjectProperty(resource.PropertyMap{})), resource.NewObjectProperty(resource.PropertyMap{ "name": resource.NewStringProperty("a"), - "value": resource.MakeComputed(resource.NewStringProperty("")), + "value": resource.MakeSecret(resource.NewStringProperty("a")), }), + resource.MakeComputed(resource.NewObjectProperty(resource.PropertyMap{})), resource.NewObjectProperty(resource.PropertyMap{ - "name": resource.NewStringProperty("b"), - "value": resource.MakeSecret(resource.NewStringProperty("b")), + "name": resource.NewStringProperty("c"), + "value": resource.MakeSecret(resource.NewStringProperty("c")), }), }), } type RepositoryOpts struct { - // Repository where to locate the requested chart. If is a URL the chart is installed without installing the repository. + // Repository where to locate the requested chart. Repo string `json:"repo,omitempty"` // The Repositories CA File CAFile string `json:"caFile,omitempty"` @@ -535,11 +539,6 @@ func Test_Extract_Example(t *testing.T) { Username string `json:"username,omitempty"` } - type Arg struct { - Name string `json:"name,omitempty"` - Value string `json:"value,omitempty"` - } - type Loader struct { Chart string `json:"chart,omitempty"` DependencyUpdate *bool `json:"dependencyUpdate,omitempty"` @@ -552,7 +551,7 @@ func Test_Extract_Example(t *testing.T) { result, err := Extract(loader, props, ExtractOptions{RejectUnknowns: false}) assert.NoError(t, err) assert.Equal(t, ExtractResult{ContainsUnknowns: false, ContainsSecrets: true}, result) - t.Logf("\n%+v", result) + t.Logf("\n%s\n%+v", printJSON(loader), result) // EXAMPLE: anonymous struct (version) version := struct { @@ -562,19 +561,20 @@ func Test_Extract_Example(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "1.24.0", version.Version) assert.Equal(t, ExtractResult{ContainsUnknowns: false, ContainsSecrets: false}, result) - t.Logf("\n%+v\n%+v", version, result) + t.Logf("\n%s\n%+v", printJSON(version), result) - // EXAMPLE: anonymous struct (namespace) + // EXAMPLE: anonymous struct ("namespace") namespace := struct { Namespace string `json:"namespace"` }{} result, err = Extract(&namespace, props, ExtractOptions{RejectUnknowns: false}) assert.NoError(t, err) assert.Equal(t, "", namespace.Namespace) - assert.Equal(t, ExtractResult{ContainsUnknowns: true, ContainsSecrets: true, Dependencies: []resource.URN{res1}}, result) - t.Logf("\n%+v\n%+v", namespace, result) + assert.Equal(t, + ExtractResult{ContainsUnknowns: true, ContainsSecrets: true, Dependencies: []resource.URN{res1}}, result) + t.Logf("\n%s\n%+v", printJSON(namespace), result) - // EXAMPLE: not present (dependencyUpdate, optional) + // EXAMPLE: unset property ("dependencyUpdate") dependencyUpdate := struct { DependencyUpdate *bool `json:"dependencyUpdate"` }{} @@ -582,16 +582,40 @@ func Test_Extract_Example(t *testing.T) { assert.NoError(t, err) assert.Nil(t, dependencyUpdate.DependencyUpdate) assert.Equal(t, ExtractResult{ContainsUnknowns: false, ContainsSecrets: false}, result) - t.Logf("\n%+v\n%+v", dependencyUpdate, result) + t.Logf("\n%s\n%+v", printJSON(dependencyUpdate), result) // EXAMPLE: arrays + type Arg struct { + Name string `json:"name"` + Value string `json:"value"` + } args := struct { - Args []Arg `json:"args"` + Args []*Arg `json:"args"` }{} result, err = Extract(&args, props, ExtractOptions{RejectUnknowns: false}) assert.NoError(t, err) - assert.Equal(t, []Arg{{Name: "", Value: ""}, {Name: "a", Value: ""}, {Name: "b", Value: "b"}}, args.Args) + assert.Equal(t, []*Arg{{Name: "a", Value: "a"}, nil, {Name: "c", Value: "c"}}, args.Args) assert.Equal(t, ExtractResult{ContainsUnknowns: true, ContainsSecrets: true}, result) - t.Logf("\n%+v\n%+v", args, result) + t.Logf("\n%s\n%+v", printJSON(args), result) + // EXAMPLE: arrays (names only) + type ArgNames struct { + Name string `json:"name"` + } + argNames := struct { + Args []*ArgNames `json:"args"` + }{} + result, err = Extract(&argNames, props, ExtractOptions{RejectUnknowns: false}) + assert.NoError(t, err) + assert.Equal(t, []*ArgNames{{Name: "a"}, nil, {Name: "c"}}, argNames.Args) + assert.Equal(t, ExtractResult{ContainsUnknowns: true, ContainsSecrets: false}, result) + t.Logf("\n%s\n%+v", printJSON(argNames), result) +} + +func printJSON(v interface{}) string { + val, err := json.MarshalIndent(v, "", " ") + if err != nil { + panic(err) + } + return string(val) } diff --git a/resourcex/traverse_test.go b/resourcex/traverse_test.go index 54ae8110..6827356a 100644 --- a/resourcex/traverse_test.go +++ b/resourcex/traverse_test.go @@ -22,6 +22,7 @@ import ( ) func Test_Traverse(t *testing.T) { + t.Parallel() // constants a := resource.NewStringProperty("a") @@ -429,9 +430,42 @@ func Test_Traverse(t *testing.T) { b, }, }, + { + name: "array_array", + path: path("a[0][0].b"), + props: resource.PropertyMap{ + "a": array( + array( + object(resource.PropertyMap{ + "b": b, + }), + ), + ), + }, + expected: []resource.PropertyValue{ + array( + array( + object(resource.PropertyMap{ + "b": b, + }), + ), + ), + array( + object(resource.PropertyMap{ + "b": b, + }), + ), + object(resource.PropertyMap{ + "b": b, + }), + b, + }, + }, } for _, tt := range tests { + tt := tt t.Run(tt.name, func(t *testing.T) { + t.Parallel() v := resource.NewObjectProperty(tt.props) var actual []resource.PropertyValue Traverse(v, tt.path, func(v resource.PropertyValue) {