diff --git a/examples/examples_nodejs_test.go b/examples/examples_nodejs_test.go index debad7ca2f..39ae44640c 100644 --- a/examples/examples_nodejs_test.go +++ b/examples/examples_nodejs_test.go @@ -97,6 +97,28 @@ func TestRefreshChanges(t *testing.T) { assert.Equal(t, 1, len(*updateSummary)) } +func TestPreviewStableProperties(t *testing.T) { + cwd := getCwd(t) + options := []opttest.Option{ + opttest.LocalProviderPath("aws-native", filepath.Join(cwd, "..", "bin")), + opttest.YarnLink("@pulumi/aws-native"), + } + test := pulumitest.NewPulumiTest(t, filepath.Join(cwd, "stable-outputs-preview"), options...) + test.SetConfig(t, "lambdaDescription", "Lambda 1") + defer func() { + test.Destroy(t) + }() + + upResult := test.Up(t) + t.Logf("#%v", upResult.Summary) + + // updating a non-replaceOnChanges property to ensure + // that the downstream resources doesn't show a replacement + test.SetConfig(t, "lambdaDescription", "Lambda 2") + previewResult := test.Preview(t) + assertpreview.HasNoReplacements(t, previewResult) +} + func TestCustomResourceEmulator(t *testing.T) { crossTest := func(t *testing.T, outputs auto.OutputMap) { require.Contains(t, outputs, "cloudformationAmiId") diff --git a/examples/stable-outputs-preview/.gitignore b/examples/stable-outputs-preview/.gitignore new file mode 100644 index 0000000000..cf84eb0ebf --- /dev/null +++ b/examples/stable-outputs-preview/.gitignore @@ -0,0 +1,3 @@ +/bin/ +/node_modules/ +handler.zip diff --git a/examples/stable-outputs-preview/Pulumi.yaml b/examples/stable-outputs-preview/Pulumi.yaml new file mode 100644 index 0000000000..b753c9bca6 --- /dev/null +++ b/examples/stable-outputs-preview/Pulumi.yaml @@ -0,0 +1,3 @@ +name: aws-native-stable-outputs-preview +runtime: nodejs +description: An example program to test stable outputs during preview diff --git a/examples/stable-outputs-preview/app/index.handler.js b/examples/stable-outputs-preview/app/index.handler.js new file mode 100644 index 0000000000..ba808b56bd --- /dev/null +++ b/examples/stable-outputs-preview/app/index.handler.js @@ -0,0 +1,8 @@ +export const handler = async () => { + return { + statusCode: 200, + body: JSON.stringify({ + message: "Hello World!", + }), + }; +}; diff --git a/examples/stable-outputs-preview/index.ts b/examples/stable-outputs-preview/index.ts new file mode 100644 index 0000000000..ff8f749ac2 --- /dev/null +++ b/examples/stable-outputs-preview/index.ts @@ -0,0 +1,49 @@ +import * as pulumi from '@pulumi/pulumi'; +import * as path from 'path'; +import * as ccapi from "@pulumi/aws-native"; +import * as aws from '@pulumi/aws'; +import { zipDirectory } from './zip'; + +const config = new pulumi.Config(); +const desc = config.get('lambdaDescription') ?? 'test lambda'; +const role = new ccapi.iam.Role('lambda-role', { + assumeRolePolicyDocument: { + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { + Service: "lambda.amazonaws.com", + }, + Action: "sts:AssumeRole", + }, + ], + }, +}); + + +const bucket = new ccapi.s3.Bucket('bucket'); +const object = new aws.s3.BucketObjectv2( + 'object', + { + source: zipDirectory(path.join(__dirname, 'app'), 'handler.zip'), + bucket: bucket.bucketName.apply(bucket => bucket!), + key: 'handler.zip', + }, +); +const handler = new ccapi.lambda.Function('my-function', { + code: { + s3Bucket: bucket.bucketName.apply(bucket => bucket!), + s3Key: object.key, + }, + description: desc, + runtime: 'nodejs20.x', + handler: 'index.handler', + role: role.arn, +}); + +new ccapi.lambda.Permission('chall-permission', { + functionName: handler.arn, + action: 'lambda:InvokeFunction', + principal: 's3.amazonaws.com', +}); diff --git a/examples/stable-outputs-preview/package.json b/examples/stable-outputs-preview/package.json new file mode 100644 index 0000000000..e284a6f764 --- /dev/null +++ b/examples/stable-outputs-preview/package.json @@ -0,0 +1,13 @@ +{ + "name": "aws-native-naming-conventions", + "main": "index.ts", + "devDependencies": { + "@types/node": "^16" + }, + "dependencies": { + "@pulumi/aws": "^6.63.0", + "@pulumi/aws-native": "^0.8.0", + "@pulumi/pulumi": "^3.74.0", + "archiver": "^7.0.1" + } +} diff --git a/examples/stable-outputs-preview/tsconfig.json b/examples/stable-outputs-preview/tsconfig.json new file mode 100644 index 0000000000..ab65afa613 --- /dev/null +++ b/examples/stable-outputs-preview/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "outDir": "bin", + "target": "es2016", + "module": "commonjs", + "moduleResolution": "node", + "sourceMap": true, + "experimentalDecorators": true, + "pretty": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "forceConsistentCasingInFileNames": true + }, + "files": [ + "index.ts" + ] +} diff --git a/examples/stable-outputs-preview/zip.ts b/examples/stable-outputs-preview/zip.ts new file mode 100644 index 0000000000..9c8adb9885 --- /dev/null +++ b/examples/stable-outputs-preview/zip.ts @@ -0,0 +1,48 @@ +import { createWriteStream, promises as fs } from 'fs'; +import * as path from 'path'; +import * as glob from 'glob'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const archiver = require('archiver'); + +export function zipDirectory(directory: string, outputFile: string): Promise { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + // The below options are needed to support following symlinks when building zip files: + // - nodir: This will prevent symlinks themselves from being copied into the zip. + // - follow: This will follow symlinks and copy the files within. + const globOptions = { + dot: true, + nodir: true, + follow: true, + cwd: directory, + }; + const files = glob.sync('**', globOptions); // The output here is already sorted + + const output = createWriteStream(outputFile); + + const archive = archiver('zip'); + archive.on('warning', reject); + archive.on('error', reject); + + // archive has been finalized and the output file descriptor has closed, resolve promise + // this has to be done before calling `finalize` since the events may fire immediately after. + // see https://www.npmjs.com/package/archiver + output.once('close', () => resolve(outputFile)); + + archive.pipe(output); + + // Append files serially to ensure file order + for (const file of files) { + const fullPath = path.resolve(directory, file); + const [data, stat] = await Promise.all([fs.readFile(fullPath), fs.stat(fullPath)]); + archive.append(data, { + name: file, + date: new Date('1980-01-01T00:00:00.000Z'), // reset dates to get the same hash for the same content + mode: stat.mode, + }); + } + + await archive.finalize(); + }); +} diff --git a/provider/pkg/outputs/previewOutputs.go b/provider/pkg/outputs/previewOutputs.go new file mode 100644 index 0000000000..33d09c4ef1 --- /dev/null +++ b/provider/pkg/outputs/previewOutputs.go @@ -0,0 +1,234 @@ +package outputs + +import ( + "fmt" + "slices" + "strings" + + "github.com/pulumi/pulumi-aws-native/provider/pkg/metadata" + "github.com/pulumi/pulumi-aws-native/provider/pkg/naming" + rSchema "github.com/pulumi/pulumi-aws-native/provider/pkg/schema" + "github.com/pulumi/pulumi/pkg/v3/codegen/schema" + "github.com/pulumi/pulumi/sdk/v3/go/common/resource" + "github.com/pulumi/pulumi/sdk/v3/go/common/tokens" +) + +// PreviewOutputs calculates the outputs of a resource based on the inputs and the output +// properties in the resource schema. +// +// During an update operation the `outputsFromPriorState` should also be provided +// in order to populate stable outputs from the prior state +func PreviewOutputs( + newInputs resource.PropertyMap, + types map[string]metadata.CloudAPIType, + outputTypes map[string]schema.PropertySpec, + readOnlyProperties []string, + resourceTypeName tokens.TypeName, + // will only be provided during an update operation + outputsFromPriorState *resource.PropertyMap, +) resource.PropertyMap { + outputsFromInputs := previewResourceOutputs(newInputs, types, outputTypes, readOnlyProperties) + if outputsFromPriorState != nil { + return populateStableOutputs(outputsFromInputs, *outputsFromPriorState, readOnlyProperties, resourceTypeName) + } + return outputsFromInputs +} + +// This calculates the outputs of a resource based on the inputs and the output +// properties in the resource schema. +// +// For example, if there is a property "someProperty" that is both an input and +// an output, the underlying type could have readonly properties meaning that part +// of the type is an output only value. Those will not exist as input values +// and will be marked as computed +func previewResourceOutputs( + inputs resource.PropertyMap, + types map[string]metadata.CloudAPIType, + outputs map[string]schema.PropertySpec, + readOnly []string, +) resource.PropertyMap { + result := resource.PropertyMap{} + for name, prop := range outputs { + key := resource.PropertyKey(name) + if inputValue, ok := inputs[key]; ok { + result[key] = previewOutputValue(inputValue, types, &prop.TypeSpec, filterAndReturnNested(readOnly, name)) + // otherwise we could have one of two cases + // 1. the property is an input & output property, but does not have an input value + // 2. the property is a readonly (output only) property + // In either case, the property could be computed so we should default to marking it as computed + } else if isReadOnly(readOnly, name) { + result[key] = resource.MakeComputed(resource.NewStringProperty("")) + } + } + return result +} + +// populateStableOutputs updates the preview outputs with the outputs from +// the prior state, but only in certain cases. +// +// There is no way (based on the schema) to tell which output properties are stable +// (i.e. they will only change if the resource is replaced). Because of this we +// cannot blanket copy all outputs from the prior state. The ones that are not stable +// should remain computed since they could be changing. +// +// Instead, we use some heuristics to determine which outputs are stable and which are not. +// We are making the assumption that Name, Id, and Arn are stable outputs. Sometimes resources +// will have `name`, `id`, and `arn` properties, and sometimes they will have `resourceName`, `resourceId`, +// and `resourceArn` properties. +func populateStableOutputs( + // outputsFromInputs has to be the output of previewResourceOutputs + // so it will contain all possible output properties that we might want + // to update + outputsFromInputs resource.PropertyMap, + outputsFromPriorState resource.PropertyMap, + readOnly []string, + resourceTypeName tokens.TypeName, +) resource.PropertyMap { + for _, readOnlyProp := range readOnly { + // For now skip outputs that are part of an array property. + // We have no way of knowing which item in the outputs array corresponds + // to which item from the inputs array (since inputs could be changing). + if strings.Contains(readOnlyProp, "/*/") { + continue + } + if isStableOutput(readOnlyProp, resourceTypeName) { + // nested property. Sometimes the resource Arn is in a nested property + if strings.Contains(readOnlyProp, "/") { + props := strings.Split(readOnlyProp, "/") + current := props[0] + key := resource.PropertyKey(current) + if outputFromInput, ok := outputsFromInputs[key]; ok { + if outputFromState, ok := outputsFromPriorState[key]; ok { + outputsFromInputs[key] = resource.NewObjectProperty(populateStableOutputs( + outputFromInput.ObjectValue(), + outputFromState.ObjectValue(), + []string{strings.Join(props[1:], "/")}, + resourceTypeName, + )) + } + } + } else { + key := resource.PropertyKey(readOnlyProp) + if output, ok := outputsFromPriorState[key]; ok { + outputsFromInputs[key] = output + } + } + } + } + return outputsFromInputs +} + +// isStableOutput determines if a property is a stable output or not. +// This uses a very conservative heuristic and does not cover all cases. +// +// e.g. sometimes the arn property does not contain the full resource type name +// - `aws-native:sagemaker:ModelExplainabilityJobDefinition` has a property `jobDefinitionArn` +// - `aws-native:securityhub:PolicyAssociation` has a property `associationIdentifier` +// - TODO[pulumi/aws-native#1892]: in some cases this property is the `primaryIdentifier`. Could we use that as another heuristic? +// a readonly property that is also a primary identifier? It doesn't catch all cases, but would catch more +func isStableOutput(propName string, resourceTypeName tokens.TypeName) bool { + typeName := naming.ToSdkName(resourceTypeName.String()) + stableOutputsNameOnly := []string{ + fmt.Sprintf("%sName", typeName), + fmt.Sprintf("%sId", typeName), + fmt.Sprintf("%sArn", typeName), + } + // These are the most common properties that are stable outputs + // and are used to determine if an output is stable or not + topLevelStableOutputs := slices.Concat([]string{ + "name", + "id", + "arn", + }, stableOutputsNameOnly) + + // we can't handle arrays because we don't know which item in the array + // the value corresponds to + if rSchema.ResourceProperty(propName).IsArrayProperty() { + return false + } + + // e.g. aws-native:s3:StorageLens has a storageLensConfiguration/StorageLensArn readOnly property + if strings.Contains(propName, "/") { + parts := strings.Split(propName, "/") + name := parts[len(parts)-1] + // If the property is a nested property then only consider it stable if + // the property contains the resource type name. There are a lot of cases where + // an object has a property called `id` or `arn` that is not the resource id or arn + return slices.Contains(stableOutputsNameOnly, name) + } + return slices.Contains(topLevelStableOutputs, propName) +} + +func isReadOnly(readOnly []string, key string) bool { + for _, prop := range readOnly { + if prop == key { + return true + } + } + return false +} + +// filterAndReturnNested will filter the readOnly nested properties and +// return the nested properties if found +// e.g. +// - if the readOnly properties are ["foo/bar", "foo/bar/baz", "somethingElse"] +// and the key is "foo" +// then the result will be ["bar", "bar/baz"] +func filterAndReturnNested(readOnly []string, key string) []string { + result := []string{} + for _, prop := range readOnly { + if strings.HasPrefix(prop, key+"/*/") { + result = append(result, strings.TrimPrefix(prop, key+"/*/")) + } else if strings.HasPrefix(prop, key+"/") { + result = append(result, strings.TrimPrefix(prop, key+"/")) + } + } + return result +} + +// previewOutputValue exists to recurse through nested objects and populate computed values +// There are cases where the input and output types are the same, but some properties of the type +// are only output values. +func previewOutputValue( + inputValue resource.PropertyValue, + types map[string]metadata.CloudAPIType, + prop *schema.TypeSpec, + readOnly []string, +) resource.PropertyValue { + switch { + case inputValue.IsSecret(): + return resource.NewSecretProperty(&resource.Secret{ + Element: previewOutputValue(inputValue.SecretValue().Element, types, prop, readOnly), + }) + case inputValue.IsOutput(): + return resource.NewOutputProperty(resource.Output{ + Element: previewOutputValue(inputValue.OutputValue().Element, types, prop, readOnly), + Known: inputValue.OutputValue().Known, + Secret: inputValue.OutputValue().Secret, + Dependencies: inputValue.OutputValue().Dependencies, + }) + case inputValue.IsArray() && (prop.Type == "array" || prop.Items != nil): + var items []resource.PropertyValue + for _, item := range inputValue.ArrayValue() { + items = append(items, previewOutputValue(item, types, prop.Items, readOnly)) + } + return resource.NewArrayProperty(items) + case inputValue.IsObject() && strings.HasPrefix(prop.Ref, "#/types/"): + typeName := strings.TrimPrefix(prop.Ref, "#/types/") + if t, ok := types[typeName]; ok { + v := previewResourceOutputs(inputValue.ObjectValue(), types, t.Properties, readOnly) + return resource.NewObjectProperty(v) + } + // AdditionalProperties (map types) + case inputValue.IsObject() && prop.AdditionalProperties != nil: + inputObject := inputValue.ObjectValue() + result := resource.PropertyMap{} + for name, value := range inputObject { + p := value + result[name] = previewOutputValue(p, types, prop.AdditionalProperties, []string{}) + } + return resource.NewObjectProperty(result) + } + // fallback to assuming input is the same as the output + return inputValue +} diff --git a/provider/pkg/outputs/previewOutputs_test.go b/provider/pkg/outputs/previewOutputs_test.go new file mode 100644 index 0000000000..b0669a010e --- /dev/null +++ b/provider/pkg/outputs/previewOutputs_test.go @@ -0,0 +1,587 @@ +package outputs + +import ( + "testing" + + "github.com/pulumi/pulumi-aws-native/provider/pkg/metadata" + "github.com/pulumi/pulumi/pkg/v3/codegen/schema" + "github.com/pulumi/pulumi/sdk/v3/go/common/resource" + "github.com/stretchr/testify/assert" +) + +func TestPreviewOutputs(t *testing.T) { + t.Run("Without prior state", func(t *testing.T) { + result := PreviewOutputs( + resource.NewPropertyMapFromMap(map[string]interface{}{ + "name": "my-resource", + }), + map[string]metadata.CloudAPIType{}, + map[string]schema.PropertySpec{ + "name": schema.PropertySpec{ + TypeSpec: schema.TypeSpec{Type: "string"}, + }, + "arn": schema.PropertySpec{ + TypeSpec: schema.TypeSpec{Type: "string"}, + }, + }, + []string{ + "arn", + }, + "AccessPoint", + nil, + ) + assert.Equal(t, resource.PropertyMap{ + "name": resource.NewStringProperty("my-resource"), + "arn": resource.MakeComputed(resource.NewStringProperty("")), + }, result) + }) + + t.Run("With prior state", func(t *testing.T) { + priorOutputs := resource.NewPropertyMapFromMap(map[string]interface{}{ + "arn": "arnvalue", + }) + result := PreviewOutputs( + resource.NewPropertyMapFromMap(map[string]interface{}{ + "name": "my-resource", + }), + map[string]metadata.CloudAPIType{}, + map[string]schema.PropertySpec{ + "name": schema.PropertySpec{ + TypeSpec: schema.TypeSpec{Type: "string"}, + }, + "arn": schema.PropertySpec{ + TypeSpec: schema.TypeSpec{Type: "string"}, + }, + }, + []string{ + "arn", + }, + "AccessPoint", + &priorOutputs, + ) + assert.Equal(t, resource.PropertyMap{ + "name": resource.NewStringProperty("my-resource"), + "arn": resource.NewStringProperty("arnvalue"), + }, result) + }) +} + +func Test_previewResourceOutputs(t *testing.T) { + t.Run("Nested output value", func(t *testing.T) { + result := previewResourceOutputs( + resource.NewPropertyMapFromMap(map[string]interface{}{ + "name": "my-access-point", + "objectLambdaConfiguration": map[string]interface{}{ + "allowedFeatures": []string{"GetObject-Range"}, + "cloudWatchMetricsEnabled": true, + }, + }), + map[string]metadata.CloudAPIType{ + "aws-native:s3objectlambda:AccessPointAlias": { + Properties: map[string]schema.PropertySpec{ + "status": { + TypeSpec: schema.TypeSpec{Type: "string"}, + }, + "value": { + TypeSpec: schema.TypeSpec{Type: "string"}, + }, + }, + }, + "aws-native:s3objectlambda:AccessPointObjectLambdaConfiguration": { + Properties: map[string]schema.PropertySpec{ + "cloudWatchMetricsEnabled": { + TypeSpec: schema.TypeSpec{Type: "boolean"}, + }, + "allowedFeatures": { + TypeSpec: schema.TypeSpec{ + Type: "array", + Items: &schema.TypeSpec{Type: "string"}, + }, + }, + }, + }, + }, + map[string]schema.PropertySpec{ + "alias": { + TypeSpec: schema.TypeSpec{ + Ref: "#/types/aws-native:s3objectlambda:AccessPointAlias", + }, + }, + "objectLambdaConfiguration": { + TypeSpec: schema.TypeSpec{ + Ref: "#/types/aws-native:s3objectlambda:AccessPointObjectLambdaConfiguration", + }, + }, + }, + []string{"alias", "alias/status", "alias/value"}, + ) + assert.Equal(t, resource.PropertyMap{ + "alias": resource.MakeComputed(resource.NewStringProperty("")), + "objectLambdaConfiguration": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "allowedFeatures": []string{"GetObject-Range"}, + "cloudWatchMetricsEnabled": true, + })), + }, result) + }) + + t.Run("Mixed input and output types", func(t *testing.T) { + result := previewResourceOutputs( + resource.NewPropertyMapFromMap(map[string]interface{}{ + "name": "my-access-point", + "objectLambdaConfiguration": map[string]interface{}{ + "allowedFeatures": []string{"GetObject-Range"}, + }, + }), + map[string]metadata.CloudAPIType{ + "aws-native:s3objectlambda:AccessPointObjectLambdaConfiguration": { + Properties: map[string]schema.PropertySpec{ + "cloudWatchMetricsEnabled": { + TypeSpec: schema.TypeSpec{Type: "boolean"}, + }, + "allowedFeatures": { + TypeSpec: schema.TypeSpec{ + Type: "array", + Items: &schema.TypeSpec{Type: "string"}, + }, + }, + }, + }, + }, + map[string]schema.PropertySpec{ + "objectLambdaConfiguration": { + TypeSpec: schema.TypeSpec{ + Ref: "#/types/aws-native:s3objectlambda:AccessPointObjectLambdaConfiguration", + }, + }, + }, + []string{ + "objectLambdaConfiguration/cloudWatchMetricsEnabled", + }, + ) + assert.Equal(t, resource.PropertyMap{ + "objectLambdaConfiguration": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "allowedFeatures": []string{"GetObject-Range"}, + "cloudWatchMetricsEnabled": resource.MakeComputed(resource.NewStringProperty("")), + })), + }, result) + }) + + t.Run("with additionalProperties", func(t *testing.T) { + result := previewResourceOutputs( + resource.NewPropertyMapFromMap(map[string]interface{}{ + "name": "my-access-point", + "objectLambdaConfiguration": map[string]interface{}{ + "allowedFeatures": "GetObject-Range", + }, + }), + map[string]metadata.CloudAPIType{}, + map[string]schema.PropertySpec{ + "objectLambdaConfiguration": { + TypeSpec: schema.TypeSpec{ + AdditionalProperties: &schema.TypeSpec{ + Type: "string", + }, + }, + }, + }, + []string{}, + ) + assert.Equal(t, resource.PropertyMap{ + "objectLambdaConfiguration": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "allowedFeatures": "GetObject-Range", + })), + }, result) + }) + + t.Run("Array with mixed input output types", func(t *testing.T) { + result := previewResourceOutputs( + resource.NewPropertyMapFromMap(map[string]interface{}{ + "subscribers": []map[string]interface{}{ + { + "address": "address", + }, + }, + }), + map[string]metadata.CloudAPIType{ + "aws-native:ce:AnomalySubscriptionSubscriber": { + Type: "object", + Properties: map[string]schema.PropertySpec{ + "address": { + TypeSpec: schema.TypeSpec{Type: "string"}, + }, + "status": { + TypeSpec: schema.TypeSpec{Type: "string"}, + }, + "type": { + TypeSpec: schema.TypeSpec{Type: "string"}, + }, + }, + }, + }, + map[string]schema.PropertySpec{ + "subscribers": { + TypeSpec: schema.TypeSpec{ + Type: "array", + Items: &schema.TypeSpec{ + Ref: "#/types/aws-native:ce:AnomalySubscriptionSubscriber", + }, + }, + }, + }, + []string{ + "subscribers/*/status", + }, + ) + assert.Equal(t, resource.PropertyMap{ + "subscribers": resource.NewArrayProperty([]resource.PropertyValue{ + resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "address": "address", + "status": resource.MakeComputed(resource.NewStringProperty("")), + })), + }), + }, result) + }) + + t.Run("with secret values", func(t *testing.T) { + result := previewResourceOutputs( + resource.PropertyMap{ + "subscribers": resource.NewSecretProperty(&resource.Secret{Element: resource.NewArrayProperty( + []resource.PropertyValue{ + resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "address": "address", + })), + }, + )}), + }, + map[string]metadata.CloudAPIType{ + "aws-native:ce:AnomalySubscriptionSubscriber": { + Type: "object", + Properties: map[string]schema.PropertySpec{ + "address": { + TypeSpec: schema.TypeSpec{Type: "string"}, + }, + "status": { + TypeSpec: schema.TypeSpec{Type: "string"}, + }, + "type": { + TypeSpec: schema.TypeSpec{Type: "string"}, + }, + }, + }, + }, + map[string]schema.PropertySpec{ + "subscribers": { + TypeSpec: schema.TypeSpec{ + Type: "array", + Items: &schema.TypeSpec{ + Ref: "#/types/aws-native:ce:AnomalySubscriptionSubscriber", + }, + }, + }, + }, + []string{ + "subscribers/*/status", + }, + ) + assert.Equal(t, resource.PropertyMap{ + "subscribers": resource.NewSecretProperty(&resource.Secret{Element: resource.NewArrayProperty( + []resource.PropertyValue{ + resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "address": "address", + "status": resource.MakeComputed(resource.NewStringProperty("")), + })), + }, + )}), + }, result) + }) + + t.Run("with output values", func(t *testing.T) { + result := previewResourceOutputs( + resource.PropertyMap{ + "subscribers": resource.NewOutputProperty(resource.Output{Element: resource.NewArrayProperty( + []resource.PropertyValue{ + resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "address": "address", + })), + }, + )}), + }, + map[string]metadata.CloudAPIType{ + "aws-native:ce:AnomalySubscriptionSubscriber": { + Type: "object", + Properties: map[string]schema.PropertySpec{ + "address": { + TypeSpec: schema.TypeSpec{Type: "string"}, + }, + "status": { + TypeSpec: schema.TypeSpec{Type: "string"}, + }, + "type": { + TypeSpec: schema.TypeSpec{Type: "string"}, + }, + }, + }, + }, + map[string]schema.PropertySpec{ + "subscribers": { + TypeSpec: schema.TypeSpec{ + Type: "array", + Items: &schema.TypeSpec{ + Ref: "#/types/aws-native:ce:AnomalySubscriptionSubscriber", + }, + }, + }, + }, + []string{ + "subscribers/*/status", + }, + ) + assert.Equal(t, resource.PropertyMap{ + "subscribers": resource.NewOutputProperty(resource.Output{Element: resource.NewArrayProperty( + []resource.PropertyValue{ + resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "address": "address", + "status": resource.MakeComputed(resource.NewStringProperty("")), + })), + }, + )}), + }, result) + }) +} + +func Test_updatePreviewWithOutputs(t *testing.T) { + t.Run("Top level stable output", func(t *testing.T) { + result := populateStableOutputs( + resource.NewPropertyMapFromMap(map[string]interface{}{ + "input": "value", + "arn": resource.MakeComputed(resource.NewStringProperty("")), + }), + resource.NewPropertyMapFromMap(map[string]interface{}{ + "input": "value", + "arn": "arn:aws:s3-object-lambda:us-west-2:123456789012:accesspoint/my-access-point", + }), + []string{ + "arn", + }, + "accesspoint", + ) + assert.Equal(t, resource.PropertyMap{ + "input": resource.NewStringProperty("value"), + "arn": resource.NewStringProperty("arn:aws:s3-object-lambda:us-west-2:123456789012:accesspoint/my-access-point"), + }, result) + }) + + t.Run("Nested output value resourceNameArn", func(t *testing.T) { + result := populateStableOutputs( + resource.NewPropertyMapFromMap(map[string]interface{}{ + "objectLambdaConfiguration": map[string]interface{}{ + "allowedFeatures": []string{"GetObject-Range"}, + "accessPointArn": resource.MakeComputed(resource.NewStringProperty("")), + }, + "alias": map[string]interface{}{ + "status": resource.MakeComputed(resource.NewStringProperty("")), + }, + }), + resource.NewPropertyMapFromMap(map[string]interface{}{ + "objectLambdaConfiguration": map[string]interface{}{ + "allowedFeatures": []string{"GetObject-Range"}, + "accessPointArn": "arn:aws:s3-object-lambda:us-west-2:123456789012:accesspoint/my-access-point", + }, + "alias": map[string]interface{}{ + "status": "ACTIVE", + }, + }), + []string{ + "objectLambdaConfiguration/accessPointArn", + "alias", + "alias/status", + }, + "AccessPoint", + ) + assert.Equal(t, resource.PropertyMap{ + "alias": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "status": resource.MakeComputed(resource.NewStringProperty("")), + })), + "objectLambdaConfiguration": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "allowedFeatures": []string{"GetObject-Range"}, + "accessPointArn": "arn:aws:s3-object-lambda:us-west-2:123456789012:accesspoint/my-access-point", + })), + }, result) + }) + + t.Run("Nested output value arn isn't stable", func(t *testing.T) { + result := populateStableOutputs( + resource.NewPropertyMapFromMap(map[string]interface{}{ + "objectLambdaConfiguration": map[string]interface{}{ + "arn": resource.MakeComputed(resource.NewStringProperty("")), + }, + }), + resource.NewPropertyMapFromMap(map[string]interface{}{ + "objectLambdaConfiguration": map[string]interface{}{ + "arn": "arn:aws:s3-object-lambda:us-west-2:123456789012:accesspoint/my-access-point", + }, + }), + []string{ + "objectLambdaConfiguration/arn", + }, + "AccessPoint", + ) + assert.Equal(t, resource.PropertyMap{ + "objectLambdaConfiguration": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "arn": resource.MakeComputed(resource.NewStringProperty("")), + })), + }, result) + }) + + t.Run("Nested output value id isn't stable", func(t *testing.T) { + result := populateStableOutputs( + resource.NewPropertyMapFromMap(map[string]interface{}{ + "objectLambdaConfiguration": map[string]interface{}{ + "id": resource.MakeComputed(resource.NewStringProperty("")), + }, + }), + resource.NewPropertyMapFromMap(map[string]interface{}{ + "objectLambdaConfiguration": map[string]interface{}{ + "id": "my-access-point", + }, + }), + []string{ + "objectLambdaConfiguration/id", + }, + "AccessPoint", + ) + assert.Equal(t, resource.PropertyMap{ + "objectLambdaConfiguration": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "id": resource.MakeComputed(resource.NewStringProperty("")), + })), + }, result) + }) + + t.Run("Nested output value resourceNameId", func(t *testing.T) { + result := populateStableOutputs( + resource.NewPropertyMapFromMap(map[string]interface{}{ + "objectLambdaConfiguration": map[string]interface{}{ + "accessPointId": resource.MakeComputed(resource.NewStringProperty("")), + }, + }), + resource.NewPropertyMapFromMap(map[string]interface{}{ + "objectLambdaConfiguration": map[string]interface{}{ + "accessPointId": "my-access-point", + }, + }), + []string{ + "objectLambdaConfiguration/accessPointId", + }, + "AccessPoint", + ) + assert.Equal(t, resource.PropertyMap{ + "objectLambdaConfiguration": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "accessPointId": "my-access-point", + })), + }, result) + }) + + t.Run("Nested output value resourceNameName", func(t *testing.T) { + result := populateStableOutputs( + resource.NewPropertyMapFromMap(map[string]interface{}{ + "objectLambdaConfiguration": map[string]interface{}{ + "accessPointName": resource.MakeComputed(resource.NewStringProperty("")), + }, + }), + resource.NewPropertyMapFromMap(map[string]interface{}{ + "objectLambdaConfiguration": map[string]interface{}{ + "accessPointName": "my-access-point", + }, + }), + []string{ + "objectLambdaConfiguration/accessPointName", + }, + "AccessPoint", + ) + assert.Equal(t, resource.PropertyMap{ + "objectLambdaConfiguration": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "accessPointName": "my-access-point", + })), + }, result) + }) + + t.Run("Nested output value name isn't stable", func(t *testing.T) { + result := populateStableOutputs( + resource.NewPropertyMapFromMap(map[string]interface{}{ + "objectLambdaConfiguration": map[string]interface{}{ + "name": resource.MakeComputed(resource.NewStringProperty("")), + }, + }), + resource.NewPropertyMapFromMap(map[string]interface{}{ + "objectLambdaConfiguration": map[string]interface{}{ + "name": "my-access-point", + }, + }), + []string{ + "objectLambdaConfiguration/name", + }, + "AccessPoint", + ) + assert.Equal(t, resource.PropertyMap{ + "objectLambdaConfiguration": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "name": resource.MakeComputed(resource.NewStringProperty("")), + })), + }, result) + }) + + t.Run("only readonly properties can be stable", func(t *testing.T) { + result := populateStableOutputs( + resource.NewPropertyMapFromMap(map[string]interface{}{ + "name": resource.MakeComputed(resource.NewStringProperty("")), + }), + resource.NewPropertyMapFromMap(map[string]interface{}{ + "name": "my-access-point", + }), + []string{}, + "AccessPoint", + ) + assert.Equal(t, resource.PropertyMap{ + "name": resource.MakeComputed(resource.NewStringProperty("")), + }, result) + }) + + t.Run("Array properties ignored", func(t *testing.T) { + result := populateStableOutputs( + resource.NewPropertyMapFromMap(map[string]interface{}{ + "objectLambdaConfiguration": map[string]interface{}{ + "arrayValue": []map[string]interface{}{ + { + "allowedFeatures": []string{"GetObject-Range"}, + "arn": resource.MakeComputed(resource.NewStringProperty("")), + }, + }, + }, + }), + resource.NewPropertyMapFromMap(map[string]interface{}{ + "objectLambdaConfiguration": map[string]interface{}{ + "arrayValue": []map[string]interface{}{ + { + "allowedFeatures": []string{"GetObject-Range"}, + "arn": "arn:aws:s3-object-lambda:us-west-2:123456789012:accesspoint/my-access-point", + }, + }, + }, + }), + []string{ + "objectLambdaConfiguration/arrayValue/*/arn", + }, + "AccessPoint", + ) + assert.Equal(t, resource.PropertyMap{ + "objectLambdaConfiguration": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "arrayValue": []map[string]interface{}{ + { + "allowedFeatures": []string{"GetObject-Range"}, + "arn": resource.MakeComputed(resource.NewStringProperty("")), + }, + }, + })), + }, result) + + }) +} diff --git a/provider/pkg/provider/provider.go b/provider/pkg/provider/provider.go index 9c94614272..fea04da65c 100644 --- a/provider/pkg/provider/provider.go +++ b/provider/pkg/provider/provider.go @@ -54,6 +54,7 @@ import ( "github.com/pulumi/pulumi-aws-native/provider/pkg/default_tags" "github.com/pulumi/pulumi-aws-native/provider/pkg/metadata" "github.com/pulumi/pulumi-aws-native/provider/pkg/naming" + pOutputs "github.com/pulumi/pulumi-aws-native/provider/pkg/outputs" "github.com/pulumi/pulumi-aws-native/provider/pkg/resources" "github.com/pulumi/pulumi-aws-native/provider/pkg/schema" "github.com/pulumi/pulumi-aws-native/provider/pkg/version" @@ -531,7 +532,8 @@ func (p *cfnProvider) Configure(ctx context.Context, req *pulumirpc.ConfigureReq p.configured = true return &pulumirpc.ConfigureResponse{ - AcceptSecrets: true, + AcceptSecrets: true, + SupportsPreview: true, }, nil } @@ -847,8 +849,32 @@ func (p *cfnProvider) Create(ctx context.Context, req *pulumirpc.CreateRequest) } resourceToken := string(urn.Type()) - var id *string + var outputs resource.PropertyMap + if req.GetPreview() { + if customResource, ok := p.customResources[resourceToken]; ok { + outputs = customResource.PreviewCustomResourceOutputs() + } else { + spec, hasSpec := p.resourceMap.Resources[resourceToken] + if !hasSpec { + return nil, errors.Errorf("Resource type %s not found", resourceToken) + } + outputs = pOutputs.PreviewOutputs(inputs, p.resourceMap.Types, spec.Outputs, spec.ReadOnly, urn.Type().Name(), nil) + } + + previewState, err := plugin.MarshalProperties( + outputs, + plugin.MarshalOptions{Label: fmt.Sprintf("%s.checkpoint", label), KeepSecrets: true, KeepUnknowns: true, SkipNulls: true}, + ) + if err != nil { + return nil, err + } + return &pulumirpc.CreateResponse{ + Properties: previewState, + }, nil + } + + var id *string var createErr error timeout := time.Duration(req.GetTimeout()) * time.Second if customResource, ok := p.customResources[resourceToken]; ok { @@ -1077,6 +1103,29 @@ func (p *cfnProvider) Update(ctx context.Context, req *pulumirpc.UpdateRequest) var outputs resource.PropertyMap id := req.GetId() resourceToken := string(urn.Type()) + + if req.GetPreview() { + if customResource, ok := p.customResources[resourceToken]; ok { + outputs = customResource.PreviewCustomResourceOutputs() + } else { + spec, hasSpec := p.resourceMap.Resources[resourceToken] + if !hasSpec { + return nil, errors.Errorf("Resource type %s not found", resourceToken) + } + resourceTypeName := urn.Type().Name() + outputs = pOutputs.PreviewOutputs(newInputs, p.resourceMap.Types, spec.Outputs, spec.ReadOnly, resourceTypeName, &oldState) + } + + previewState, err := plugin.MarshalProperties( + outputs, + plugin.MarshalOptions{Label: fmt.Sprintf("%s.checkpoint", label), KeepSecrets: true, KeepUnknowns: true, SkipNulls: true}, + ) + if err != nil { + return nil, err + } + return &pulumirpc.UpdateResponse{Properties: previewState}, nil + } + if customResource, ok := p.customResources[resourceToken]; ok { timeout := time.Duration(req.GetTimeout()) * time.Second // Custom resource diff --git a/provider/pkg/provider/provider_test.go b/provider/pkg/provider/provider_test.go index 020f1c3472..34b61221bb 100644 --- a/provider/pkg/provider/provider_test.go +++ b/provider/pkg/provider/provider_test.go @@ -102,6 +102,158 @@ func TestConfigure(t *testing.T) { }) } +func TestCreatePreview(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockCCC := client.NewMockCloudControlClient(ctrl) + mockCustomResource := resources.NewMockCustomResource(ctrl) + + ctx, cancel := context.WithCancel(context.Background()) + provider := &cfnProvider{ + name: "test-provider", + resourceMap: &metadata.CloudAPIMetadata{Resources: map[string]metadata.CloudAPIResource{ + "aws-native:s3:Bucket": metadata.CloudAPIResource{ + CfType: "AWS::S3::Bucket", + Outputs: map[string]schema.PropertySpec{ + "arn": {TypeSpec: schema.TypeSpec{Type: "string"}}, + "bucketName": {TypeSpec: schema.TypeSpec{Type: "string"}}, + }, + ReadOnly: []string{"arn", "domainName", "dualStackDomainName", "regionalDomainName", "websiteUrl"}, + }, + }}, + customResources: map[string]resources.CustomResource{"custom:resource": mockCustomResource}, + ccc: mockCCC, + canceler: &cancellationContext{ + context: ctx, + cancel: cancel, + }, + } + + urn := resource.NewURN("stack", "project", "parent", "custom:resource", "name") + + t.Run("Outputs are computed", func(t *testing.T) { + req := &pulumirpc.CreateRequest{ + Urn: string(urn), + Preview: true, + Properties: mustMarshalProperties(t, resource.PropertyMap{"bucketName": resource.NewStringProperty("name")}), + Timeout: float64((5 * time.Minute).Seconds()), + } + req.Urn = string(resource.NewURN("stack", "project", "parent", "aws-native:s3:Bucket", "name")) + + resp, err := provider.Create(ctx, req) + assert.NoError(t, err) + assert.Empty(t, resp.Id) + require.NotNil(t, resp.Properties) + props := mustUnmarshalProperties(t, resp.Properties) + require.True(t, props.HasValue("arn"), "Expected 'arn' property in response") + require.True(t, props.HasValue("bucketName"), "Expected 'bucketName' property in response") + assert.Equal(t, "name", props["bucketName"].StringValue()) + assert.True(t, props["arn"].IsComputed()) + }) + + t.Run("Outputs are computed for custom resource", func(t *testing.T) { + req := &pulumirpc.CreateRequest{ + Urn: string(urn), + Preview: true, + Properties: mustMarshalProperties(t, resource.PropertyMap{"bucketName": resource.NewStringProperty("name")}), + Timeout: float64((5 * time.Minute).Seconds()), + } + req.Urn = string(resource.NewURN("stack", "project", "parent", "custom:resource", "name")) + + mockCustomResource.EXPECT().PreviewCustomResourceOutputs().Return( + resource.PropertyMap{"outputs": resource.MakeComputed(resource.NewStringProperty(""))}, + ) + + resp, err := provider.Create(ctx, req) + assert.NoError(t, err) + assert.Empty(t, resp.Id) + require.NotNil(t, resp.Properties) + props := mustUnmarshalProperties(t, resp.Properties) + require.True(t, props.HasValue("outputs"), "Expected 'outputs' property in response") + assert.True(t, props["outputs"].IsComputed()) + }) +} + +func TestUpdatePreview(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockCCC := client.NewMockCloudControlClient(ctrl) + mockCustomResource := resources.NewMockCustomResource(ctrl) + + ctx, cancel := context.WithCancel(context.Background()) + provider := &cfnProvider{ + name: "test-provider", + resourceMap: &metadata.CloudAPIMetadata{Resources: map[string]metadata.CloudAPIResource{ + "aws-native:s3:Bucket": metadata.CloudAPIResource{ + CfType: "AWS::S3::Bucket", + Outputs: map[string]schema.PropertySpec{ + "arn": {TypeSpec: schema.TypeSpec{Type: "string"}}, + "bucketName": {TypeSpec: schema.TypeSpec{Type: "string"}}, + }, + ReadOnly: []string{"arn", "domainName", "dualStackDomainName", "regionalDomainName", "websiteUrl"}, + }, + }}, + customResources: map[string]resources.CustomResource{"custom:resource": mockCustomResource}, + ccc: mockCCC, + canceler: &cancellationContext{ + context: ctx, + cancel: cancel, + }, + } + + urn := resource.NewURN("stack", "project", "parent", "custom:resource", "name") + + t.Run("Stable outputs appear in preview", func(t *testing.T) { + req := &pulumirpc.UpdateRequest{ + Urn: string(urn), + Preview: true, + Olds: mustMarshalProperties(t, resource.PropertyMap{ + "bucketName": resource.NewStringProperty("name"), + "arn": resource.NewStringProperty("bucketArn"), + }), + News: mustMarshalProperties(t, resource.PropertyMap{"bucketName": resource.NewStringProperty("name")}), + Timeout: float64((5 * time.Minute).Seconds()), + } + req.Urn = string(resource.NewURN("stack", "project", "parent", "aws-native:s3:Bucket", "name")) + + resp, err := provider.Update(ctx, req) + assert.NoError(t, err) + require.NotNil(t, resp.Properties) + props := mustUnmarshalProperties(t, resp.Properties) + require.True(t, props.HasValue("arn"), "Expected 'arn' property in response") + require.True(t, props.HasValue("bucketName"), "Expected 'bucketName' property in response") + assert.Equal(t, "name", props["bucketName"].StringValue()) + assert.Equal(t, "bucketArn", props["arn"].StringValue()) + }) + + t.Run("custom resource", func(t *testing.T) { + req := &pulumirpc.UpdateRequest{ + Urn: string(urn), + Preview: true, + Olds: mustMarshalProperties(t, resource.PropertyMap{ + "bucketName": resource.NewStringProperty("name"), + "arn": resource.NewStringProperty("bucketArn"), + }), + News: mustMarshalProperties(t, resource.PropertyMap{"bucketName": resource.NewStringProperty("name")}), + Timeout: float64((5 * time.Minute).Seconds()), + } + req.Urn = string(resource.NewURN("stack", "project", "parent", "custom:resource", "name")) + + mockCustomResource.EXPECT().PreviewCustomResourceOutputs().Return( + resource.PropertyMap{"data": resource.MakeComputed(resource.NewStringProperty(""))}, + ) + + resp, err := provider.Update(ctx, req) + assert.NoError(t, err) + require.NotNil(t, resp.Properties) + props := mustUnmarshalProperties(t, resp.Properties) + require.True(t, props.HasValue("data"), "Expected 'data' property in response") + assert.True(t, props["data"].IsComputed()) + }) +} + func TestCreate(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/provider/pkg/resources/cfn_custom_resource.go b/provider/pkg/resources/cfn_custom_resource.go index 3200563f3d..482109377d 100644 --- a/provider/pkg/resources/cfn_custom_resource.go +++ b/provider/pkg/resources/cfn_custom_resource.go @@ -685,3 +685,11 @@ func sanitizeCustomResourceResponse(event *cfn.Event, response *cfn.Response) *c return response } + +// cfn custom resources have outputs returned in a "data" property +// since it can be any arbitrary data, we mark the entire thing as computed +func (c *cfnCustomResource) PreviewCustomResourceOutputs() resource.PropertyMap { + return resource.PropertyMap{ + "data": resource.MakeComputed(resource.NewStringProperty("")), + } +} diff --git a/provider/pkg/resources/custom.go b/provider/pkg/resources/custom.go index f730772ed8..e67ef0491e 100644 --- a/provider/pkg/resources/custom.go +++ b/provider/pkg/resources/custom.go @@ -21,4 +21,6 @@ type CustomResource interface { Update(ctx context.Context, urn resource.URN, id string, inputs, oldInputs, state resource.PropertyMap, timeout time.Duration) (resource.PropertyMap, error) // Delete removes the resource from the cloud provider. Delete(ctx context.Context, urn resource.URN, id string, inputs, state resource.PropertyMap, timeout time.Duration) error + // PreviewCustomResourceOutputs returns the outputs of the resource based on the inputs and the output properties in the resource schema. + PreviewCustomResourceOutputs() resource.PropertyMap } diff --git a/provider/pkg/resources/extension_resource.go b/provider/pkg/resources/extension_resource.go index c3ce163cce..8e05c139b2 100644 --- a/provider/pkg/resources/extension_resource.go +++ b/provider/pkg/resources/extension_resource.go @@ -334,3 +334,11 @@ func (r *ExtensionResourceInputs) AddWriteOnlyProps(resourceState map[string]int } return appended } + +// extension resources have outputs returned in an "outputs" property +// since the outputs can be arbitrary we just mark the entire thing as computed +func (r *extensionResource) PreviewCustomResourceOutputs() resource.PropertyMap { + return resource.PropertyMap{ + "outputs": resource.MakeComputed(resource.NewStringProperty("")), + } +} diff --git a/provider/pkg/resources/mock_custom_resource.go b/provider/pkg/resources/mock_custom_resource.go index e778268c8f..ce398063dd 100644 --- a/provider/pkg/resources/mock_custom_resource.go +++ b/provider/pkg/resources/mock_custom_resource.go @@ -88,6 +88,20 @@ func (mr *MockCustomResourceMockRecorder) Delete(ctx, urn, id, inputs, state, ti return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockCustomResource)(nil).Delete), ctx, urn, id, inputs, state, timeout) } +// PreviewCustomResourceOutputs mocks base method. +func (m *MockCustomResource) PreviewCustomResourceOutputs() resource.PropertyMap { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PreviewCustomResourceOutputs") + ret0, _ := ret[0].(resource.PropertyMap) + return ret0 +} + +// PreviewCustomResourceOutputs indicates an expected call of PreviewCustomResourceOutputs. +func (mr *MockCustomResourceMockRecorder) PreviewCustomResourceOutputs() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PreviewCustomResourceOutputs", reflect.TypeOf((*MockCustomResource)(nil).PreviewCustomResourceOutputs)) +} + // Read mocks base method. func (m *MockCustomResource) Read(ctx context.Context, urn resource.URN, id string, oldInputs, oldOutputs resource.PropertyMap) (resource.PropertyMap, resource.PropertyMap, bool, error) { m.ctrl.T.Helper() diff --git a/provider/pkg/schema/resource_props.go b/provider/pkg/schema/resource_props.go index 20caadd08f..1aadc35448 100644 --- a/provider/pkg/schema/resource_props.go +++ b/provider/pkg/schema/resource_props.go @@ -29,3 +29,7 @@ func (r ResourceProperty) ToSdkName() string { } return strings.Join(arrayProps, "/*/") } + +func (r ResourceProperty) IsArrayProperty() bool { + return strings.Contains(string(r), "/*/") +}