Repository: pulumi/pulumi-cdk
Pull request #191 (2024-11-01 12:27:54)

Fix resource mappings to multiple resources
Typically there will be a couple of scenarios for how a CFN resource maps to Pulumi resources:

1. The CFN resource maps to a single Pulumi aws-native resource
   This is the most straightforward case because everything maps directly, including the attributes

2. The CFN resource maps to a single Pulumi aws resources
   In this case the mapping is to a single resource, but some times the attributes available
   on the CFN resource do not map to the attributes on the Pulumi resource. In these cases
   the mapping can return custom attributes that are later available when references are resolved

3. The CFN resource maps to multiple Pulumi aws resources
   One example would be the AWS::IAM::Policy resource which in CFN includes Role, Group, and User
   Polices, but in Pulumi aws these are broken out into separate resources. In that case the \"main\"
   resource would be the Policy and the other resources would be added to the resources array.
   The critical point here is that the \"main\" resource needs to have a logicalId that matches the
   logicalId of the CFN resource, while the other supporting resources must have different logicalIds.

This PR fixes the 3rd case. Previously we were not handling the other
supporting resources.


Patch:
diff --git a/README.md b/README.md
index 3502fbd..faf3b30 100644
--- a/README.md
+++ b/README.md
@@ -167,7 +167,7 @@ CustomResources, commonly by using the AWS Classic provider to implement the
 missing resource.
 
 ```ts
-remapCloudControlResource(logicalId: string, typeName: string, props: any, options: pulumi.ResourceOptions): { [key: string]: pulumi.CustomResource } | undefined
+remapCloudControlResource(logicalId: string, typeName: string, props: any, options: pulumi.ResourceOptions): ResourceMapping | undefined;
 ```
 
 Parameters:
diff --git a/examples/cron-lambda/adapter.ts b/examples/cron-lambda/adapter.ts
deleted file mode 100644
index 5732cc4..0000000
--- a/examples/cron-lambda/adapter.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { CfnElement } from 'aws-cdk-lib';
-import * as pulumi from '@pulumi/pulumi';
-import * as pulumicdk from '@pulumi/cdk';
-import * as aws from '@pulumi/aws';
-
-interface target {
-    arn: pulumi.Input<string>;
-    id: string;
-}
-
-export function remapCloudControlResource(
-    _element: CfnElement,
-    logicalId: string,
-    typeName: string,
-    rawProps: any,
-    options: pulumi.ResourceOptions,
-): pulumi.CustomResource | undefined {
-    const props = pulumicdk.interop.normalize(rawProps);
-    switch (typeName) {
-        case 'AWS::Events::Rule': {
-            const rule = new aws.cloudwatch.EventRule(
-                logicalId,
-                {
-                    scheduleExpression: props['scheduleExpression'],
-                    isEnabled: props['state'] == 'ENABLED' ? true : props.State === 'DISABLED' ? false : undefined,
-                    description: props.Description,
-                    eventBusName: props['eventBusName'] ?? undefined,
-                    eventPattern: props['eventPattern'] ?? undefined,
-                    roleArn: props['roleArn'] ?? undefined,
-                },
-                options,
-            );
-            const targets: target[] = props['targets'] ?? [];
-            for (const t of targets) {
-                new aws.cloudwatch.EventTarget(
-                    t.id,
-                    {
-                        arn: t.arn,
-                        rule: rule.name,
-                    },
-                    options,
-                );
-            }
-            return rule;
-        }
-        case 'AWS::Lambda::Permission':
-            return new aws.lambda.Permission(
-                logicalId,
-                {
-                    action: props['action'],
-                    function: props['functionName'],
-                    principal: props['principal'],
-                    sourceArn: props['sourceArn'] ?? undefined,
-                },
-                options,
-            );
-    }
-
-    return undefined;
-}
diff --git a/examples/lookups/index.ts b/examples/lookups/index.ts
index 281df63..33a8653 100644
--- a/examples/lookups/index.ts
+++ b/examples/lookups/index.ts
@@ -127,25 +127,23 @@ const app = new pulumicdk.App(
             remapCloudControlResource(logicalId, typeName, props, options) {
                 switch (typeName) {
                     case 'AWS::Route53::RecordSet':
-                        return [
-                            new aws.route53.Record(
-                                logicalId,
-                                {
-                                    zoneId: props.HostedZoneId,
-                                    aliases: [
-                                        {
-                                            name: props.AliasTarget.DNSName,
-                                            zoneId: props.AliasTarget.HostedZoneId,
-                                            evaluateTargetHealth: props.AliasTarget.EvaluateTargetHealth ?? false,
-                                        },
-                                    ],
-                                    name: props.Name,
-                                    type: props.Type,
-                                    records: props.ResourceRecords,
-                                },
-                                options,
-                            ),
-                        ];
+                        return new aws.route53.Record(
+                            logicalId,
+                            {
+                                zoneId: props.HostedZoneId,
+                                aliases: [
+                                    {
+                                        name: props.AliasTarget.DNSName,
+                                        zoneId: props.AliasTarget.HostedZoneId,
+                                        evaluateTargetHealth: props.AliasTarget.EvaluateTargetHealth ?? false,
+                                    },
+                                ],
+                                name: props.Name,
+                                type: props.Type,
+                                records: props.ResourceRecords,
+                            },
+                            options,
+                        );
                     default:
                         return undefined;
                 }
diff --git a/src/aws-resource-mappings.ts b/src/aws-resource-mappings.ts
index 9c076e0..496b4fa 100644
--- a/src/aws-resource-mappings.ts
+++ b/src/aws-resource-mappings.ts
@@ -14,7 +14,7 @@
 
 import * as pulumi from '@pulumi/pulumi';
 import * as aws from '@pulumi/aws';
-import { ResourceMapping, normalize } from './interop';
+import { ResourceAttributeMappingArray, ResourceMapping, normalize } from './interop';
 
 function maybe<T, U>(v: T | undefined, fn: (t: T) => U): U | undefined {
     if (v === undefined) {
@@ -46,44 +46,40 @@ export function mapToAwsResource(
     typeName: string,
     rawProps: any,
     options: pulumi.ResourceOptions,
-): ResourceMapping[] | undefined {
+): ResourceMapping | undefined {
     const props = normalize(rawProps);
     switch (typeName) {
         // ApiGatewayV2
         case 'AWS::ApiGatewayV2::Integration':
-            return [
-                new aws.apigatewayv2.Integration(
-                    logicalId,
-                    {
-                        ...props,
-                        requestParameters: rawProps.RequestParameters,
-                        requestTemplates: rawProps.RequestTemplates,
-                        responseParameters: rawProps.ResponseParameters,
-                        tlsConfig: maybe(props.tlsConfig, () => ({ insecureSkipVerification: true })),
-                    },
-                    options,
-                ),
-            ];
+            return new aws.apigatewayv2.Integration(
+                logicalId,
+                {
+                    ...props,
+                    requestParameters: rawProps.RequestParameters,
+                    requestTemplates: rawProps.RequestTemplates,
+                    responseParameters: rawProps.ResponseParameters,
+                    tlsConfig: maybe(props.tlsConfig, () => ({ insecureSkipVerification: true })),
+                },
+                options,
+            );
         case 'AWS::ApiGatewayV2::Stage':
-            return [
-                new aws.apigatewayv2.Stage(
-                    logicalId,
-                    {
-                        accessLogSettings: props.accessLogSettings,
-                        apiId: props.apiId,
-                        autoDeploy: props.autoDeploy,
-                        clientCertificateId: props.clientCertificateId,
-                        defaultRouteSettings: props.defaultRouteSettings,
-                        deploymentId: props.deploymentId,
-                        description: props.description,
-                        name: props.stageName,
-                        routeSettings: props.routeSettings,
-                        stageVariables: rawProps.StageVariables,
-                        tags: tags(props.tags),
-                    },
-                    options,
-                ),
-            ];
+            return new aws.apigatewayv2.Stage(
+                logicalId,
+                {
+                    accessLogSettings: props.accessLogSettings,
+                    apiId: props.apiId,
+                    autoDeploy: props.autoDeploy,
+                    clientCertificateId: props.clientCertificateId,
+                    defaultRouteSettings: props.defaultRouteSettings,
+                    deploymentId: props.deploymentId,
+                    description: props.description,
+                    name: props.stageName,
+                    routeSettings: props.routeSettings,
+                    stageVariables: rawProps.StageVariables,
+                    tags: tags(props.tags),
+                },
+                options,
+            );
 
         // SQS
         // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sqs-queuepolicy.html
@@ -92,31 +88,42 @@ export function mapToAwsResource(
                 throw new Error('QueuePolicy has an invalid value for `queues` property');
             }
 
-            return (props.queues || []).flatMap((q: string) => {
-                return new aws.sqs.QueuePolicy(logicalId, {
-                    policy: rawProps.PolicyDocument,
-                    queueUrl: q,
-                });
+            const queues: string[] = props.queues ?? [];
+            return queues.flatMap((q: string, i: number) => {
+                const id = i === 0 ? logicalId : `${logicalId}-policy-${i}`;
+                return {
+                    logicalId: id,
+                    resource: new aws.sqs.QueuePolicy(id, {
+                        policy: rawProps.PolicyDocument,
+                        queueUrl: q,
+                    }),
+                };
             });
         }
 
         // SNS
         // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sns-topicpolicy.html
-        case 'AWS::SNS::TopicPolicy':
+        case 'AWS::SNS::TopicPolicy': {
             if (!Array.isArray(props.topics)) {
                 throw new Error('TopicPolicy has an invalid value for `topics` property');
             }
 
-            return (props.topics || []).flatMap((arn: string) => {
-                return new aws.sns.TopicPolicy(logicalId, {
-                    policy: rawProps.PolicyDocument,
-                    arn,
-                });
+            const topics: string[] = props.topics ?? [];
+            return topics.flatMap((arn: string, i: number) => {
+                const id = i === 0 ? logicalId : `${logicalId}-policy-${i}`;
+                return {
+                    logicalId: id,
+                    resource: new aws.sns.TopicPolicy(id, {
+                        policy: rawProps.PolicyDocument,
+                        arn,
+                    }),
+                };
             });
+        }
 
         // IAM
         case 'AWS::IAM::Policy': {
-            const resources: ResourceMapping[] = [];
+            const resources: ResourceAttributeMappingArray = [];
             const policy = new aws.iam.Policy(
                 logicalId,
                 {
@@ -124,44 +131,64 @@ export function mapToAwsResource(
                 },
                 options,
             );
-            resources.push(policy);
-
-            for (let i = 0; i < (props.groups || []).length; i++) {
-                resources.push(
-                    new aws.iam.GroupPolicyAttachment(
-                        `${logicalId}-${i}`,
-                        {
-                            group: props.groups[i],
-                            policyArn: policy.arn,
-                        },
-                        options,
-                    ),
-                );
-            }
-            for (let i = 0; i < (props.roles || []).length; i++) {
-                resources.push(
-                    new aws.iam.RolePolicyAttachment(
-                        `${logicalId}-${i}`,
-                        {
-                            role: props.roles[i],
-                            policyArn: policy.arn,
-                        },
-                        options,
-                    ),
-                );
-            }
-            for (let i = 0; i < (props.users || []).length; i++) {
-                resources.push(
-                    new aws.iam.UserPolicyAttachment(
-                        `${logicalId}-${i}`,
-                        {
-                            user: props.users[i],
-                            policyArn: policy.arn,
-                        },
-                        options,
-                    ),
-                );
-            }
+            resources.push({
+                resource: policy,
+                logicalId,
+            });
+
+            const groups: string[] = props.groups ?? [];
+            resources.push(
+                ...groups.flatMap((group: string, i: number) => {
+                    const id = `${logicalId}-group-${i}`;
+                    return {
+                        logicalId: id,
+                        resource: new aws.iam.GroupPolicyAttachment(
+                            id,
+                            {
+                                group,
+                                policyArn: policy.arn,
+                            },
+                            options,
+                        ),
+                    };
+                }),
+            );
+
+            const roles: string[] = props.roles ?? [];
+            resources.push(
+                ...roles.flatMap((role: string, i: number) => {
+                    const id = `${logicalId}-role-${i}`;
+                    return {
+                        logicalId: id,
+                        resource: new aws.iam.RolePolicyAttachment(
+                            id,
+                            {
+                                role,
+                                policyArn: policy.arn,
+                            },
+                            options,
+                        ),
+                    };
+                }),
+            );
+
+            const users: string[] = props.users ?? [];
+            resources.push(
+                ...users.flatMap((user: string, i: number) => {
+                    const id = `${logicalId}-user-${i}`;
+                    return {
+                        logicalId: id,
+                        resource: new aws.iam.UserPolicyAttachment(
+                            id,
+                            {
+                                user,
+                                policyArn: policy.arn,
+                            },
+                            options,
+                        ),
+                    };
+                }),
+            );
 
             return resources;
         }
diff --git a/src/cfn-resource-mappings.ts b/src/cfn-resource-mappings.ts
index 6dfd565..cf9be5a 100644
--- a/src/cfn-resource-mappings.ts
+++ b/src/cfn-resource-mappings.ts
@@ -25,14 +25,14 @@ export function mapToCfnResource(
     typeName: string,
     rawProps: any,
     options: pulumi.ResourceOptions,
-): ResourceMapping[] {
+): ResourceMapping {
     const props = normalize(rawProps, typeName);
     debug(`mapToCfnResource typeName: ${typeName} props: ${JSON.stringify(props)}`);
     switch (typeName) {
         case 'AWS::S3::Bucket':
             // Lowercase the bucket name to comply with the Bucket resource's naming constraints, which only allow
             // lowercase letters.
-            return [new aws.s3.Bucket(logicalId.toLowerCase(), props, options)];
+            return new aws.s3.Bucket(logicalId.toLowerCase(), props, options);
 
         // A couple of ApiGateway resources suffer from https://github.com/pulumi/pulumi-cdk/issues/173
         // These are very popular resources so handling the workaround here since we can remove these
@@ -41,60 +41,52 @@ export function mapToCfnResource(
             const res = new aws.apigateway.Model(logicalId, props, options);
             const attributes = Object.getOwnPropertyDescriptors(res);
 
-            return [
-                {
-                    resource: res,
-                    attributes: {
-                        ...attributes,
-                        id: res.name,
-                    },
+            return {
+                resource: res,
+                attributes: {
+                    ...attributes,
+                    id: res.name,
                 },
-            ];
+            };
         }
 
         case 'AWS::ApiGateway::Resource': {
             const res = new aws.apigateway.Resource(logicalId, props, options);
             const attributes = Object.getOwnPropertyDescriptors(res);
 
-            return [
-                {
-                    resource: res,
-                    attributes: {
-                        ...attributes,
-                        id: res.resourceId,
-                    },
+            return {
+                resource: res,
+                attributes: {
+                    ...attributes,
+                    id: res.resourceId,
                 },
-            ];
+            };
         }
 
         case 'AWS::ApiGateway::Deployment': {
             const res = new aws.apigateway.Deployment(logicalId, props, options);
             const attributes = Object.getOwnPropertyDescriptors(res);
 
-            return [
-                {
-                    attributes: {
-                        ...attributes,
-                        id: res.deploymentId,
-                    },
-                    resource: res,
+            return {
+                attributes: {
+                    ...attributes,
+                    id: res.deploymentId,
                 },
-            ];
+                resource: res,
+            };
         }
 
         case 'AWS::ApiGateway::Stage': {
             const res = new aws.apigateway.Stage(logicalId, props, options);
             const attributes = Object.getOwnPropertyDescriptors(res);
 
-            return [
-                {
-                    attributes: {
-                        ...attributes,
-                        id: res.stageName,
-                    },
-                    resource: res,
+            return {
+                attributes: {
+                    ...attributes,
+                    id: res.stageName,
                 },
-            ];
+                resource: res,
+            };
         }
         default: {
             // When creating a generic `CfnResource` we don't have any information on the
@@ -104,7 +96,7 @@ export function mapToCfnResource(
             const resource = metadata.findResource(typeName);
             const attributes = Object.keys(resource.outputs);
 
-            return [new CfnResource(logicalId, typeName, props, attributes, options)];
+            return new CfnResource(logicalId, typeName, props, attributes, options);
         }
     }
 }
diff --git a/src/converters/app-converter.ts b/src/converters/app-converter.ts
index c426bdd..21cd611 100644
--- a/src/converters/app-converter.ts
+++ b/src/converters/app-converter.ts
@@ -1,10 +1,10 @@
 import * as cdk from 'aws-cdk-lib/core';
 import * as pulumi from '@pulumi/pulumi';
 import { AssemblyManifestReader, StackManifest } from '../assembly';
-import { ConstructInfo, GraphBuilder } from '../graph';
+import { ConstructInfo, GraphBuilder, GraphNode } from '../graph';
 import { ArtifactConverter } from './artifact-converter';
 import { lift, Mapping, AppComponent } from '../types';
-import { CdkConstruct, ResourceMapping } from '../interop';
+import { CdkConstruct, ResourceAttributeMapping, ResourceMapping } from '../interop';
 import { debug } from '@pulumi/pulumi/log';
 import {
     cidr,
@@ -141,12 +141,7 @@ export class StackConverter extends ArtifactConverter {
                 const options = this.processOptions(cfn, parent);
 
                 const mapped = this.mapResource(n.logicalId, cfn.Type, props, options);
-                mapped.forEach((m) => {
-                    const resource = pulumi.Resource.isInstance(m) ? m : m.resource;
-                    const attributes = pulumi.Resource.isInstance(m) ? undefined : m.attributes;
-                    this.resources.set(n.logicalId!, { resource, attributes, resourceType: cfn.Type });
-                    this.constructs.set(n.construct, resource);
-                });
+                this.registerResource(mapped, n);
 
                 debug(`Done creating resource for ${n.logicalId}`);
                 // TODO: process template conditions
@@ -169,6 +164,72 @@ export class StackConverter extends ArtifactConverter {
         }
     }
 
+    /**
+     * This function takes a Pulumi resource that was mapped from a CFN resource and
+     * "registers" it to the StackConverter. The StackConverter keeps track of all of the
+     * resources that were mapped from the CFN resources in the stack. It uses this information
+     * to resolve references to other resources in the stack.
+     *
+     * Typically there will be a couple of scenarios for how a CFN resource maps to Pulumi resources:
+     *
+     * 1. The CFN resource maps to a single Pulumi aws-native resource
+     *    This is the most straightforward case because everything maps directly, including the attributes
+     *
+     * 2. The CFN resource maps to single Pulumi aws resources
+     *    In this case the mapping is to a single resource, but some times the attributes available
+     *    on the CFN resource do not map to the attributes on the Pulumi resource. In these cases
+     *    the mapping can return custom attributes that are later available when references are resolved
+     *
+     * 3. The CFN resource maps to multiple Pulumi aws resources
+     *    One example would be the AWS::IAM::Policy resource which in CFN includes Role, Group, and User
+     *    Polices, but in Pulumi aws these are broken out into separate resources. In that case the "main"
+     *    resource would be the Policy and the other resources would be added to the resources array.
+     *    The critical point here is that the "main" resource needs to have a logicalId that matches the
+     *    logicalId of the CFN resource, while the other supporting resources must have different logicalIds.
+     *
+     * @param mapped - The Pulumi Resource that was mapped from the CFN resource
+     * @param node - The GraphNode that represents the CFN Resource
+     */
+    private registerResource(mapped: ResourceMapping, node: GraphNode): void {
+        const cfn = node.resource;
+        // This should always be set because we only call this function when it is, but
+        // TypeScript doesn't know that.
+        if (!cfn) {
+            throw new Error('Cannot map a resource without a CloudFormation resource');
+        }
+        const mainResource: ResourceAttributeMapping | undefined = Array.isArray(mapped)
+            ? mapped.find((res) => res.logicalId === node.logicalId)
+            : pulumi.Resource.isInstance(mapped)
+            ? { resource: mapped }
+            : mapped;
+        if (!mainResource) {
+            throw new Error(
+                `Resource mapping for ${node.logicalId} of type ${cfn.Type} did not return a primary resource. \n` +
+                    'Examine your code in "remapCloudControlResource"',
+            );
+        }
+        const otherResources: pulumi.Resource[] | undefined = Array.isArray(mapped)
+            ? mapped
+                  .filter((map) => map.logicalId !== node.logicalId)
+                  .flatMap((m) => {
+                      this.resources.set(m.logicalId, {
+                          resource: m.resource,
+                          attributes: m.attributes,
+                          resourceType: cfn.Type,
+                      });
+                      return m.resource;
+                  })
+            : undefined;
+        const resourceMapping: Mapping<pulumi.Resource> = {
+            resource: mainResource.resource,
+            attributes: mainResource.attributes,
+            resourceType: cfn.Type,
+            otherResources,
+        };
+        this.constructs.set(node.construct, mainResource.resource);
+        this.resources.set(node.logicalId!, resourceMapping);
+    }
+
     private stackDependsOn(dependencies: Set<ArtifactConverter>): pulumi.Resource[] {
         const dependsOn: pulumi.Resource[] = [];
         dependsOn.push(...this.app.dependencies);
@@ -211,7 +272,7 @@ export class StackConverter extends ArtifactConverter {
         typeName: string,
         props: any,
         options: pulumi.ResourceOptions,
-    ): ResourceMapping[] {
+    ): ResourceMapping {
         if (this.app.appOptions?.remapCloudControlResource !== undefined) {
             const res = this.app.appOptions.remapCloudControlResource(logicalId, typeName, props, options);
             if (res !== undefined) {
@@ -235,7 +296,16 @@ export class StackConverter extends ArtifactConverter {
         const dependsOn = getDependsOn(resource);
         return {
             parent: parent,
-            dependsOn: dependsOn !== undefined ? dependsOn.map((id) => this.resources.get(id)!.resource) : undefined,
+            dependsOn: dependsOn?.flatMap((id) => {
+                const resource = this.resources.get(id);
+                if (resource === undefined) {
+                    throw new Error(`Something went wrong, resource with logicalId '${id}' not found`);
+                }
+                if (resource.otherResources && resource.otherResources.length > 0) {
+                    return resource.otherResources;
+                }
+                return resource!.resource;
+            }),
         };
     }
 
diff --git a/src/interop.ts b/src/interop.ts
index b903259..69c9913 100644
--- a/src/interop.ts
+++ b/src/interop.ts
@@ -58,12 +58,31 @@ export function normalize(value: any, cfnType?: string, pulumiProvider?: PulumiP
     return result;
 }
 
-export type ResourceMapping =
-    | {
-          resource: pulumi.Resource;
-          attributes: { [name: string]: pulumi.Input<any> };
-      }
-    | pulumi.Resource;
+/**
+ * Use this type if you need to control the attributes that are available on the
+ * mapped resource. For example if the CFN resource has an attribute called `resourceArn` and
+ * the mapped resource only has an attribute called `arn` you can return the extra `resourceArn`
+ * attribute
+ *
+ * @example
+ * return {
+ *   resource: mappedResource,
+ *   attributes: {
+ *     resourceArn: mappedResource.arn,
+ *   }
+ * }
+ */
+export type ResourceAttributeMapping = {
+    resource: pulumi.Resource;
+    attributes?: { [name: string]: pulumi.Input<any> };
+};
+
+/**
+ * Use this type if a single CFN resource maps to multiple AWS resources
+ */
+export type ResourceAttributeMappingArray = (ResourceAttributeMapping & { logicalId: string })[];
+
+export type ResourceMapping = ResourceAttributeMapping | pulumi.Resource | ResourceAttributeMappingArray;
 
 export class CfnResource extends pulumi.CustomResource {
     constructor(
diff --git a/src/types.ts b/src/types.ts
index de58d1a..977c006 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -44,7 +44,7 @@ export interface AppOptions {
         typeName: string,
         props: any,
         options: pulumi.ResourceOptions,
-    ): ResourceMapping[] | undefined;
+    ): ResourceMapping | undefined;
 }
 
 /**
@@ -104,9 +104,37 @@ export interface AppComponent {
     readonly dependencies: CdkConstruct[];
 }
 
+/**
+ * Represents a mapping from a CloudFormation resource to a Pulumi resource
+ */
 export type Mapping<T extends pulumi.Resource> = {
+    /**
+     * The main resource that is being mapped.
+     * This Pulumi resource will have a `name` that matches the `logicalId`
+     * of the CloudFormation resource
+     */
     resource: T;
+
+    /**
+     * The CFN Resource type that is being mapped
+     */
     resourceType: string;
+
+    /**
+     * Other resources that are created as part of the mapping.
+     * For example, the AWS::IAM::Policy resource will also create
+     * aws.RolePolicyAttachment, aws.GroupPolicyAttachment, and aws.UserPolicyAttachment
+     * resources
+     */
+    otherResources?: T[];
+
+    /**
+     * Override the attributes that are available on the mapped resource.
+     * This is useful for when the attributes of the CFN resource do not match
+     * the attributes of the Pulumi resource, e.g. the CFN resource has an attribute
+     * called `resourceArn` and the Pulumi resource has an attribute called `arn`
+     * you would provide { attributes: { resourceArn: mappedResource.arn } }
+     */
     attributes?: { [name: string]: pulumi.Input<any> };
 };
 


Test patch:
diff --git a/tests/aws-resource-mappings.test.ts b/tests/aws-resource-mappings.test.ts
index 5f0f4ba..0a5add7 100644
--- a/tests/aws-resource-mappings.test.ts
+++ b/tests/aws-resource-mappings.test.ts
@@ -82,7 +82,7 @@ describe('AWS Resource Mappings', () => {
             {},
         );
         expect(aws.iam.GroupPolicyAttachment).toHaveBeenCalledWith(
-            `${logicalId}-0`,
+            `${logicalId}-group-0`,
             expect.objectContaining({
                 group: 'my-group',
                 policyArn: undefined,
@@ -90,7 +90,7 @@ describe('AWS Resource Mappings', () => {
             {},
         );
         expect(aws.iam.RolePolicyAttachment).toHaveBeenCalledWith(
-            `${logicalId}-0`,
+            `${logicalId}-role-0`,
             expect.objectContaining({
                 role: 'my-role',
                 policyArn: undefined,
@@ -98,7 +98,7 @@ describe('AWS Resource Mappings', () => {
             {},
         );
         expect(aws.iam.UserPolicyAttachment).toHaveBeenCalledWith(
-            `${logicalId}-0`,
+            `${logicalId}-user-0`,
             expect.objectContaining({
                 user: 'my-user',
                 policyArn: undefined,
@@ -147,7 +147,7 @@ describe('AWS Resource Mappings', () => {
             }),
         );
         expect(aws.sns.TopicPolicy).toHaveBeenCalledWith(
-            logicalId,
+            `${logicalId}-policy-1`,
             expect.objectContaining({
                 arn: 'my-other-topic',
                 policy: {
@@ -205,7 +205,7 @@ describe('AWS Resource Mappings', () => {
         );
 
         expect(aws.sqs.QueuePolicy).toHaveBeenCalledWith(
-            logicalId,
+            `${logicalId}-policy-1`,
             expect.objectContaining({
                 queueUrl: 'my-other-queue',
                 policy: {