Repository: pulumi/pulumi-cdk
Pull request #228 (2024-11-18 20:45:27)

Handle pulumi.Output recursively when translating CDK properties to Pulumi
`pulumi.Output` values seem to be able to reach the `normalize` step of translating properties from a CDK notation to a Pulumi notation and making sure the casing is right. This may be happening in particular because CF intrinsic evaluator may emit Output values. With the fix, the `normalize` step can deeply recur into these eventual values and make sure that the property casing is correctly handled regardless of whether a value is an eventual or not.

This seems to resolve an issue I have been running into when testing a Kinesis Stream.

t0yv0: Iterating on the integration failure, it is rather interesting.

Patch:
diff --git a/src/interop.ts b/src/interop.ts
index 919fbe9..6e99fb5 100644
--- a/src/interop.ts
+++ b/src/interop.ts
@@ -1,4 +1,4 @@
-// Copyright 2016-2022, Pulumi Corporation.
+// Copyright 2016-2024, Pulumi Corporation.
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -38,18 +38,27 @@ export function firstToLower(str: string) {
 export function normalize(value: any, cfnType?: string, pulumiProvider?: PulumiProvider): any {
     if (!value) return value;
 
+    if (value instanceof Promise) {
+        return pulumi.output(value).apply(v => normalize(v, cfnType, pulumiProvider));
+    }
+
+    if (pulumi.Output.isInstance(value)) {
+        return value.apply(v => normalize(v, cfnType, pulumiProvider));
+    }
+
     if (Array.isArray(value)) {
         const result: any[] = [];
         for (let i = 0; i < value.length; i++) {
-            result[i] = normalize(value[i], cfnType);
+            result[i] = normalize(value[i], cfnType, pulumiProvider);
         }
         return result;
     }
 
-    if (typeof value !== 'object' || pulumi.Output.isInstance(value) || value instanceof Promise) {
+    if (typeof value !== 'object') {
         return value;
     }
 
+    // The remaining case is the object type, representing either Maps or Object types with known field types in Pulumi.
     const result: any = {};
     Object.entries(value).forEach(([k, v]) => {
         result[toSdkName(k)] = normalizeObject([k], v, cfnType, pulumiProvider);
diff --git a/src/pulumi-metadata.ts b/src/pulumi-metadata.ts
index 284f32c..c4ee574 100644
--- a/src/pulumi-metadata.ts
+++ b/src/pulumi-metadata.ts
@@ -1,10 +1,25 @@
+// Copyright 2016-2024, Pulumi Corporation.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
 import * as path from 'path';
-import * as pulumi from '@pulumi/pulumi';
 import { toSdkName, typeToken } from './naming';
 // eslint-disable-next-line @typescript-eslint/no-var-requires
 const pulumiMetadata = require(path.join(__dirname, '../schemas/aws-native-metadata.json'));
 import { PulumiProvider } from './types';
 import { debug } from '@pulumi/pulumi/log';
+import * as pulumi from '@pulumi/pulumi';
+
 
 export class UnknownCfnType extends Error {
     constructor(cfnType: string) {
@@ -269,6 +284,15 @@ export function getNativeType(
  */
 export function normalizeObject(key: string[], value: any, cfnType?: string, pulumiProvider?: PulumiProvider): any {
     if (!value) return value;
+
+    if (value instanceof Promise) {
+        return pulumi.output(value).apply(v => normalizeObject(key, v, cfnType, pulumiProvider));
+    }
+
+    if (pulumi.Output.isInstance(value)) {
+        return value.apply(v => normalizeObject(key, v, cfnType, pulumiProvider));
+    }
+
     if (Array.isArray(value)) {
         const result: any[] = [];
         for (let i = 0; i < value.length; i++) {
@@ -277,10 +301,11 @@ export function normalizeObject(key: string[], value: any, cfnType?: string, pul
         return result;
     }
 
-    if (typeof value !== 'object' || pulumi.Output.isInstance(value) || value instanceof Promise) {
+    if (typeof value !== 'object') {
         return value;
     }
 
+    // The remaining case is the actual object type.
     const result: any = {};
     if (cfnType) {
         try {


Test patch:
diff --git a/tests/interop.test.ts b/tests/interop.test.ts
index 9609de3..b022c82 100644
--- a/tests/interop.test.ts
+++ b/tests/interop.test.ts
@@ -1,4 +1,5 @@
 import { normalize } from '../src/interop';
+import * as pulumi from '@pulumi/pulumi';
 
 beforeEach(() => {
     jest.resetAllMocks();
@@ -339,4 +340,31 @@ describe('normalize', () => {
             },
         });
     });
+
+    test('normalize changes the case of nested properties through an Output eventual type', async () => {
+        const normalized = normalize({
+            'StreamEncryption': pulumi.output({
+                EncryptionType: 'KMS',
+                KeyId: 'alias/aws/kinesis'
+            })
+        });
+
+        const finalValue = await awaitOutput(pulumi.output(normalized));
+
+        expect(finalValue).toEqual({
+            streamEncryption: {
+                encryptionType: 'KMS',
+                keyId: 'alias/aws/kinesis',
+            }
+        });
+    });
 });
+
+function awaitOutput<T>(out: pulumi.Output<T>): Promise<T> {
+    return new Promise((resolve, _reject) => {
+        out.apply(v => {
+            resolve(v);
+            return v;
+        })
+    });
+}