Repository: pulumi/pulumi-cdk
Pull request #197 (2024-11-04 19:24:45)

Fix IPV6 cidr blocks
Depends on https://github.com/pulumi/pulumi-aws-native/pull/1797

Because of https://github.com/pulumi/pulumi-aws-native/issues/1798 we need to add a special case for Ipv6 cidr blocks that are added to the VPC via a `VPCCidrBlock` resource.

This PR adds special handling where we track both the `Vpc` and the `VpcCidrBlock` resource and swap any references.


Patch:
diff --git a/.gitignore b/.gitignore
index 905ae9d..06c3fc7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -120,3 +120,9 @@ lib/
 
 **/cdk.out
 **/cdk.context.json
+
+# direnv
+.envrc
+
+# Emacs
+.dir-locals.el
diff --git a/integration/ec2/Pulumi.yaml b/integration/ec2/Pulumi.yaml
new file mode 100644
index 0000000..d876e33
--- /dev/null
+++ b/integration/ec2/Pulumi.yaml
@@ -0,0 +1,3 @@
+name: pulumi-aws-ec2
+runtime: nodejs
+description: ec2 integration test
diff --git a/integration/ec2/index.ts b/integration/ec2/index.ts
new file mode 100644
index 0000000..b8d3f47
--- /dev/null
+++ b/integration/ec2/index.ts
@@ -0,0 +1,127 @@
+import * as aws from '@pulumi/aws';
+import * as iam from 'aws-cdk-lib/aws-iam';
+import * as ec2 from 'aws-cdk-lib/aws-ec2';
+import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
+import * as pulumicdk from '@pulumi/cdk';
+import { SecretValue } from 'aws-cdk-lib/core';
+
+class Ec2Stack extends pulumicdk.Stack {
+    constructor(app: pulumicdk.App, id: string, options?: pulumicdk.StackOptions) {
+        super(app, id, options);
+        const vpc = new ec2.Vpc(this, 'Vpc', {
+            maxAzs: 2,
+            ipProtocol: ec2.IpProtocol.DUAL_STACK,
+            vpnGateway: true,
+            ipAddresses: ec2.IpAddresses.cidr('10.0.0.0/16'),
+            natGateways: 1,
+            vpnConnections: {
+                dynamic: {
+                    ip: '1.2.3.4',
+                    tunnelOptions: [
+                        {
+                            preSharedKeySecret: SecretValue.unsafePlainText('secretkey1234'),
+                        },
+                        {
+                            preSharedKeySecret: SecretValue.unsafePlainText('secretkey5678'),
+                        },
+                    ],
+                },
+                static: {
+                    ip: '4.5.6.7',
+                    staticRoutes: ['192.168.10.0/24', '192.168.20.0/24'],
+                },
+            },
+            subnetConfiguration: [
+                {
+                    name: 'Public',
+                    subnetType: ec2.SubnetType.PUBLIC,
+                },
+                {
+                    name: 'Private',
+                    subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
+                },
+                {
+                    name: 'Isolated',
+                    subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
+                },
+            ],
+            restrictDefaultSecurityGroup: false,
+        });
+
+        vpc.addFlowLog('FlowLogs', {
+            destination: ec2.FlowLogDestination.toCloudWatchLogs(),
+        });
+
+        vpc.addGatewayEndpoint('Dynamo', {
+            service: ec2.GatewayVpcEndpointAwsService.DYNAMODB,
+        });
+        vpc.addInterfaceEndpoint('ecr', {
+            service: ec2.InterfaceVpcEndpointAwsService.ECR_DOCKER,
+        });
+
+        new ec2.PrefixList(this, 'PrefixList', {});
+        const nacl = new ec2.NetworkAcl(this, 'NetworkAcl', {
+            vpc,
+            subnetSelection: { subnetType: ec2.SubnetType.PUBLIC },
+        });
+        nacl.addEntry('AllowAll', {
+            cidr: ec2.AclCidr.anyIpv4(),
+            ruleAction: ec2.Action.ALLOW,
+            ruleNumber: 100,
+            traffic: ec2.AclTraffic.allTraffic(),
+        });
+        new ec2.KeyPair(this, 'KeyPair');
+
+        const nlb = new elbv2.NetworkLoadBalancer(this, 'NLB1', { vpc });
+        new ec2.VpcEndpointService(this, 'EndpointService', {
+            vpcEndpointServiceLoadBalancers: [nlb],
+            allowedPrincipals: [new iam.ArnPrincipal('ec2.amazonaws.com')],
+        });
+    }
+}
+
+new pulumicdk.App(
+    'app',
+    (scope: pulumicdk.App) => {
+        new Ec2Stack(scope, 'teststack');
+    },
+    {
+        appOptions: {
+            remapCloudControlResource: (logicalId, typeName, props, options) => {
+                if (typeName === 'AWS::EC2::VPNGatewayRoutePropagation') {
+                    const tableIds: string[] = props.RouteTableIds;
+                    return tableIds.flatMap((tableId, i) => {
+                        const id = i === 0 ? logicalId : `${logicalId}-${i}`;
+                        return {
+                            logicalId: id,
+                            resource: new aws.ec2.VpnGatewayRoutePropagation(
+                                id,
+                                {
+                                    routeTableId: tableId,
+                                    vpnGatewayId: props.VpnGatewayId,
+                                },
+                                options,
+                            ),
+                        };
+                    });
+                }
+                if (typeName === 'AWS::EC2::NetworkAclEntry') {
+                    return new aws.ec2.NetworkAclRule(logicalId, {
+                        egress: props.Egress,
+                        toPort: props.PortRange?.To,
+                        fromPort: props.PortRange?.From,
+                        protocol: props.Protocol,
+                        ruleNumber: props.RuleNumber,
+                        networkAclId: props.NetworkAclId,
+                        ruleAction: props.RuleAction,
+                        cidrBlock: props.CidrBlock,
+                        ipv6CidrBlock: props.Ipv6CidrBlock,
+                        icmpCode: props.Icmp?.Code,
+                        icmpType: props.Icmp?.Type,
+                    });
+                }
+                return undefined;
+            },
+        },
+    },
+);
diff --git a/integration/ec2/package.json b/integration/ec2/package.json
new file mode 100644
index 0000000..b9eaf5d
--- /dev/null
+++ b/integration/ec2/package.json
@@ -0,0 +1,15 @@
+{
+    "name": "pulumi-aws-cdk",
+    "devDependencies": {
+        "@types/node": "^10.0.0"
+    },
+    "dependencies": {
+        "@pulumi/aws": "^6.0.0",
+        "@pulumi/aws-native": "^1.6.0",
+        "@pulumi/cdk": "^0.5.0",
+        "@pulumi/pulumi": "^3.0.0",
+        "aws-cdk-lib": "2.149.0",
+        "constructs": "10.3.0",
+        "esbuild": "^0.24.0"
+    }
+}
diff --git a/integration/ec2/tsconfig.json b/integration/ec2/tsconfig.json
new file mode 100644
index 0000000..eac442c
--- /dev/null
+++ b/integration/ec2/tsconfig.json
@@ -0,0 +1,18 @@
+{
+    "compilerOptions": {
+        "strict": true,
+        "outDir": "bin",
+        "target": "es2019",
+        "module": "commonjs",
+        "moduleResolution": "node",
+        "sourceMap": true,
+        "experimentalDecorators": true,
+        "pretty": true,
+        "noFallthroughCasesInSwitch": true,
+        "noImplicitReturns": true,
+        "forceConsistentCasingInFileNames": true
+    },
+    "include": [
+        "./*.ts"
+    ]
+}
diff --git a/package.json b/package.json
index ff2caa7..51acfea 100644
--- a/package.json
+++ b/package.json
@@ -28,7 +28,7 @@
   "devDependencies": {
     "@aws-cdk/aws-apprunner-alpha": "2.20.0-alpha.0",
     "@pulumi/aws": "^6.32.0",
-    "@pulumi/aws-native": "^1.0.0",
+    "@pulumi/aws-native": "^1.6.0",
     "@pulumi/docker": "^4.5.0",
     "@pulumi/pulumi": "3.121.0",
     "@types/archiver": "^6.0.2",
diff --git a/src/converters/app-converter.ts b/src/converters/app-converter.ts
index 21cd611..679d63f 100644
--- a/src/converters/app-converter.ts
+++ b/src/converters/app-converter.ts
@@ -1,7 +1,7 @@
 import * as cdk from 'aws-cdk-lib/core';
 import * as pulumi from '@pulumi/pulumi';
 import { AssemblyManifestReader, StackManifest } from '../assembly';
-import { ConstructInfo, GraphBuilder, GraphNode } from '../graph';
+import { ConstructInfo, Graph, GraphBuilder, GraphNode } from '../graph';
 import { ArtifactConverter } from './artifact-converter';
 import { lift, Mapping, AppComponent } from '../types';
 import { CdkConstruct, ResourceAttributeMapping, ResourceMapping } from '../interop';
@@ -94,6 +94,7 @@ export class StackConverter extends ArtifactConverter {
     private readonly cdkStack: cdk.Stack;
 
     private _stackResource?: CdkConstruct;
+    private readonly graph: Graph;
 
     public get stackResource(): CdkConstruct {
         if (!this._stackResource) {
@@ -105,17 +106,16 @@ export class StackConverter extends ArtifactConverter {
     constructor(host: AppComponent, readonly stack: StackManifest) {
         super(host);
         this.cdkStack = host.stacks[stack.id];
+        this.graph = GraphBuilder.build(this.stack);
     }
 
     public convert(dependencies: Set<ArtifactConverter>) {
-        const dependencyGraphNodes = GraphBuilder.build(this.stack);
-
         // process parameters first because resources will reference them
         for (const [logicalId, value] of Object.entries(this.stack.parameters ?? {})) {
             this.mapParameter(logicalId, value.Type, value.Default);
         }
 
-        for (const n of dependencyGraphNodes) {
+        for (const n of this.graph.nodes) {
             if (n.construct.id === this.stack.id) {
                 this._stackResource = new CdkConstruct(`${this.app.name}/${n.construct.path}`, n.construct.id, {
                     parent: this.app.component,
@@ -156,8 +156,8 @@ export class StackConverter extends ArtifactConverter {
             }
         }
 
-        for (let i = dependencyGraphNodes.length - 1; i >= 0; i--) {
-            const n = dependencyGraphNodes[i];
+        for (let i = this.graph.nodes.length - 1; i >= 0; i--) {
+            const n = this.graph.nodes[i];
             if (!n.resource) {
                 (<CdkConstruct>this.constructs.get(n.construct)!).done();
             }
@@ -359,7 +359,23 @@ export class StackConverter extends ArtifactConverter {
     private resolveIntrinsic(fn: string, params: any) {
         switch (fn) {
             case 'Fn::GetAtt': {
-                debug(`Fn::GetAtt(${params[0]}, ${params[1]})`);
+                const logicalId = params[0];
+                const attributeName = params[1];
+                debug(`Fn::GetAtt(${logicalId}, ${attributeName})`);
+                // Special case for VPC Ipv6CidrBlocks
+                // Ipv6 cidr blocks are added to the VPC through a separate VpcCidrBlock resource
+                // Due to [pulumi/pulumi-aws-native#1798] the `Ipv6CidrBlocks` attribute will always be empty
+                // and we need to instead pull the `Ipv6CidrBlock` attribute from the VpcCidrBlock resource.
+                if (
+                    logicalId in this.graph.vpcNodes &&
+                    attributeName === 'Ipv6CidrBlocks' &&
+                    this.graph.vpcNodes[logicalId].vpcCidrBlockNode?.logicalId
+                ) {
+                    return [
+                        this.resolveAtt(this.graph.vpcNodes[logicalId].vpcCidrBlockNode.logicalId, 'Ipv6CidrBlock'),
+                    ];
+                }
+
                 return this.resolveAtt(params[0], params[1]);
             }
 
@@ -375,17 +391,17 @@ export class StackConverter extends ArtifactConverter {
             case 'Fn::Base64':
                 return lift((str) => Buffer.from(str).toString('base64'), this.processIntrinsics(params));
 
-            case 'Fn::Cidr':
+            case 'Fn::Cidr': {
                 return lift(
                     ([ipBlock, count, cidrBits]) =>
                         cidr({
                             ipBlock,
-                            count,
-                            cidrBits,
+                            count: parseInt(count, 10),
+                            cidrBits: parseInt(cidrBits, 10),
                         }).then((r) => r.subnets),
                     this.processIntrinsics(params),
                 );
-
+            }
             case 'Fn::GetAZs':
                 return lift(([region]) => getAzs({ region }).then((r) => r.azs), this.processIntrinsics(params));
 
diff --git a/src/graph.ts b/src/graph.ts
index b915665..467a087 100644
--- a/src/graph.ts
+++ b/src/graph.ts
@@ -109,21 +109,55 @@ function typeFromFqn(fqn: string): PulumiResourceType {
     return `${mod}:${type}`;
 }
 
+/**
+ * Represents the dependency graph of the constructs in the CDK app
+ */
+export interface Graph {
+    /**
+     * The nodes in the graph sorted in topological order
+     */
+    nodes: GraphNode[];
+
+    /**
+     * The VPC nodes in the graph
+     */
+    vpcNodes: { [logicalId: string]: VpcGraphNode };
+}
+
+/**
+ * Represents a VPC and its (optionally) associated VPCCidrBlock
+ */
+export interface VpcGraphNode {
+    /**
+     * The GraphNode representing the VPC
+     */
+    vpcNode: GraphNode;
+
+    /**
+     * The GraphNode representing the VPCCidrBlock
+     */
+    vpcCidrBlockNode?: GraphNode;
+}
+
 export class GraphBuilder {
     // Allows for easy access to the GraphNode of a specific Construct
     constructNodes: Map<ConstructInfo, GraphNode>;
     // Map of resource logicalId to GraphNode. Allows for easy lookup by logicalId
     cfnElementNodes: Map<string, GraphNode>;
 
+    // If the app has a VpcCidrBlock resource, this will be set to the GraphNode representing it
+    private readonly _vpcCidrBlockNodes: { [logicalId: string]: GraphNode } = {};
+    // If the app has a Vpc resource, this will be set to the GraphNode representing it
+    private readonly vpcNodes: { [logicalId: string]: VpcGraphNode } = {};
+
     constructor(private readonly stack: StackManifest) {
         this.constructNodes = new Map<ConstructInfo, GraphNode>();
         this.cfnElementNodes = new Map<string, GraphNode>();
     }
 
     // build constructs a dependency graph from the adapter and returns its nodes sorted in topological order.
-    public static build(stack: StackManifest): GraphNode[] {
-        const b = new GraphBuilder(stack);
-        return b._build();
+    public static build(stack: StackManifest): Graph {
+        return new GraphBuilder(stack)._build();
     }
 
     /**
@@ -163,6 +197,12 @@ export class GraphBuilder {
                     `Something went wrong: resourceType ${resource.Type} does not equal CfnType ${cfnType}`,
                 );
             }
+            if (resource.Type === 'AWS::EC2::VPCCidrBlock') {
+                this._vpcCidrBlockNodes[node.logicalId] = node;
+            }
+            if (resource.Type === 'AWS::EC2::VPC') {
+                this.vpcNodes[node.logicalId] = { vpcNode: node, vpcCidrBlockNode: undefined };
+            }
         }
         this.constructNodes.set(construct, node);
         if (tree.children) {
@@ -170,7 +210,7 @@ export class GraphBuilder {
         }
     }
 
-    private _build(): GraphNode[] {
+    private _build(): Graph {
         // passes
         // 1. collect all constructs into a map from construct name to DAG node, converting CFN elements to fragments
         // 2. hook up dependency edges
@@ -181,6 +221,31 @@ export class GraphBuilder {
         // NOTE: this doesn't handle cross-stack references. We'll likely need to do so, at least for nested stacks.
         this.parseTree(this.stack.constructTree);
 
+        // parseTree does not guarantee that the VPC resource will be parsed before the VPCCidrBlock resource
+        // so we need to process this separately after
+        if (Object.keys(this._vpcCidrBlockNodes).length) {
+            Object.entries(this._vpcCidrBlockNodes).forEach(([logicalId, node]) => {
+                const resource = node.resource;
+                if (!resource) {
+                    throw new Error(`Something went wrong. CFN Resource not found for VPCCidrBlock ${logicalId}`);
+                }
+                const vpcRef = resource.Properties.VpcId;
+                if (typeof vpcRef === 'object' && 'Ref' in vpcRef) {
+                    const vpcLogicalId = this.cfnElementNodes.get(vpcRef.Ref)?.logicalId;
+                    if (!vpcLogicalId) {
+                        throw new Error(`VPC resource ${vpcRef.Ref} not found for VPCCidrBlock ${node.logicalId}`);
+                    }
+                    const vpcNode = this.vpcNodes[vpcLogicalId];
+                    // currently the CDK VPC only supports a single VPCCidrBlock per VPC so for now we won't allow multiple
+                    // if we get requests for this we can update this to support multiple
+                    if (vpcNode.vpcCidrBlockNode) {
+                        throw new Error(`VPC ${vpcLogicalId} already has a VPCCidrBlock`);
+                    }
+                    vpcNode.vpcCidrBlockNode = node;
+                }
+            });
+        }
+
         for (const [construct, node] of this.constructNodes) {
             // No parent means this is the construct that represents the `Stack`
             if (construct.parent !== undefined) {
@@ -228,7 +293,10 @@ export class GraphBuilder {
             sort(node);
         }
 
-        return sorted;
+        return {
+            nodes: sorted,
+            vpcNodes: this.vpcNodes,
+        };
     }
 
     private addEdgesForCfnResource(obj: any, source: GraphNode): void {
@@ -285,9 +353,25 @@ export class GraphBuilder {
 
     private addEdgesForIntrinsic(fn: string, params: any, source: GraphNode) {
         switch (fn) {
-            case 'Fn::GetAtt':
-                this.addEdgeForRef(params[0], source);
+            case 'Fn::GetAtt': {
+                let logicalId = params[0];
+                const attributeName = params[1];
+                // Special case for VPC Ipv6CidrBlocks
+                // Ipv6 cidr blocks are added to the VPC through a separate VpcCidrBlock resource
+                // Due to [pulumi/pulumi-aws-native#1798] the `Ipv6CidrBlocks` attribute will always be empty
+                // and we need to instead pull the `Ipv6CidrBlock` attribute from the VpcCidrBlock resource.
+                // Here we switching the dependency to be on the `VpcCidrBlock` resource (since that will also have a dependency
+                // on the VPC resource)
+                if (
+                    logicalId in this.vpcNodes &&
+                    attributeName === 'Ipv6CidrBlocks' &&
+                    this.vpcNodes[logicalId].vpcCidrBlockNode?.logicalId
+                ) {
+                    logicalId = this.vpcNodes[logicalId].vpcCidrBlockNode!.logicalId;
+                }
+                this.addEdgeForRef(logicalId, source);
                 break;
+            }
             case 'Fn::Sub':
                 {
                     const [template, vars] =
diff --git a/src/interop.ts b/src/interop.ts
index 69c9913..452e0da 100644
--- a/src/interop.ts
+++ b/src/interop.ts
@@ -94,7 +94,9 @@ export class CfnResource extends pulumi.CustomResource {
     ) {
         const resourceName = typeToken(type);
 
-        debug(`CfnResource ${resourceName}: ${JSON.stringify(properties)}, ${JSON.stringify(attributes)}`);
+        debug(`Constructing CfnResource ${name} of type ${resourceName} with attributes=${JSON.stringify(attributes)}`);
+        const propertiesDebugString = pulumi.output(properties).apply(JSON.stringify);
+        pulumi.interpolate`CfnResource ${name} input properties: ${propertiesDebugString}`.apply(debug);
 
         // Prepare an args bag with placeholders for output attributes.
         const args: any = {};
diff --git a/yarn.lock b/yarn.lock
index 9fbaa23..92dbb49 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1028,10 +1028,10 @@
   resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
   integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==
 
-"@pulumi/aws-native@^1.0.0":
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/@pulumi/aws-native/-/aws-native-1.3.0.tgz#10f654daa1cc578ab78a25ef614f888dd23e3276"
-  integrity sha512-egocWUmAmrRk+/LWof3yWdn+qrLy9rHUmrg5XjRP1SUo7pQgqEYMKY6IlV/81NV2zcdk6t65YOmumOsTFFoMuQ==
+"@pulumi/aws-native@^1.6.0":
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/@pulumi/aws-native/-/aws-native-1.6.0.tgz#8947829fb7fdcef8ce62fe43082b668068281101"
+  integrity sha512-7b6CVr8XoprdQCWCnmPDhHe+BGHN2LSk+qpeELp8M2YjcwEu2XkFhI7bAj2UjQLcD84HwPM07baM5f2uryrZxA==
   dependencies:
     "@pulumi/pulumi" "^3.136.0"
 


Test patch:
diff --git a/integration/examples_nodejs_test.go b/integration/examples_nodejs_test.go
index 0832dfa..b7469e6 100644
--- a/integration/examples_nodejs_test.go
+++ b/integration/examples_nodejs_test.go
@@ -41,6 +41,15 @@ func TestApiGatewayDomain(t *testing.T) {
 	integration.ProgramTest(t, &test)
 }
 
+func TestEc2(t *testing.T) {
+	test := getJSBaseOptions(t).
+		With(integration.ProgramTestOptions{
+			Dir: filepath.Join(getCwd(t), "ec2"),
+		})
+
+	integration.ProgramTest(t, &test)
+}
+
 func getJSBaseOptions(t *testing.T) integration.ProgramTestOptions {
 	base := getBaseOptions(t)
 	baseJS := base.With(integration.ProgramTestOptions{
diff --git a/tests/converters/app-converter.test.ts b/tests/converters/app-converter.test.ts
index e76c8bb..f59dce3 100644
--- a/tests/converters/app-converter.test.ts
+++ b/tests/converters/app-converter.test.ts
@@ -1,6 +1,7 @@
 import { AppConverter, StackConverter } from '../../src/converters/app-converter';
+import * as native from '@pulumi/aws-native';
 import { Stack } from 'aws-cdk-lib/core';
-import { AppComponent, AppOptions, PulumiStack } from '../../src/types';
+import { AppComponent, AppOptions } from '../../src/types';
 import * as path from 'path';
 import * as mockfs from 'mock-fs';
 import * as pulumi from '@pulumi/pulumi';
@@ -8,11 +9,13 @@ import { BucketPolicy } from '@pulumi/aws-native/s3';
 import { createStackManifest } from '../utils';
 import { promiseOf, setMocks } from '../mocks';
 import { CdkConstruct } from '../../src/interop';
+import { StackManifest } from '../../src/assembly';
+import { MockResourceArgs } from '@pulumi/pulumi/runtime';
 
 class MockAppComponent extends pulumi.ComponentResource implements AppComponent {
     public readonly name = 'stack';
     public readonly assemblyDir: string;
-    stacks: { [artifactId: string]: PulumiStack } = {};
+    stacks: { [artifactId: string]: Stack } = {};
     dependencies: CdkConstruct[] = [];
 
     component: pulumi.ComponentResource;
@@ -25,8 +28,10 @@ class MockAppComponent extends pulumi.ComponentResource implements AppComponent
     }
 }
 
+let resources: MockResourceArgs[] = [];
 beforeAll(() => {
-    setMocks();
+    resources = [];
+    setMocks(resources);
 });
 
 describe('App Converter', () => {
@@ -267,6 +272,192 @@ describe('App Converter', () => {
     );
 });
 
+describe('Stack Converter', () => {
+    test('can convert', async () => {
+        const manifest = new StackManifest({
+            id: 'stack',
+            templatePath: 'test/stack',
+            metadata: {
+                'stack/vpc': 'vpc',
+                'stack/cidr': 'cidr',
+                'stack/other': 'other',
+            },
+            tree: {
+                path: 'stack',
+                id: 'stack',
+                children: {
+                    vpc: {
+                        id: 'vpc',
+                        path: 'stack/vpc',
+                        attributes: {
+                            'aws:cdk:cloudformation:type': 'AWS::EC2::VPC',
+                        },
+                    },
+                    cidr: {
+                        id: 'cidr',
+                        path: 'stack/cidr',
+                        attributes: {
+                            'aws:cdk:cloudformation:type': 'AWS::EC2::VPCCidrBlock',
+                        },
+                    },
+                    other: {
+                        id: 'other',
+                        path: 'stack/other',
+                        attributes: {
+                            'aws:cdk:cloudformation:type': 'AWS::EC2::Subnet',
+                        },
+                    },
+                },
+                constructInfo: {
+                    fqn: 'aws-cdk-lib.Stack',
+                    version: '2.149.0',
+                },
+            },
+            template: {
+                Resources: {
+                    vpc: {
+                        Type: 'AWS::EC2::VPC',
+                        Properties: {},
+                    },
+                    cidr: {
+                        Type: 'AWS::EC2::VPCCidrBlock',
+                        Properties: {
+                            VpcId: { Ref: 'vpc' },
+                            Ipv6CidrBlock: 'cidr_ipv6AddressAttribute',
+                        },
+                    },
+                    other: {
+                        Type: 'AWS::EC2::Subnet',
+                        Properties: {
+                            Ipv6CidrBlock: { 'Fn::Select': [0, { 'Fn::GetAtt': ['vpc', 'Ipv6CidrBlocks'] }] },
+                        },
+                    },
+                },
+            },
+            dependencies: [],
+        });
+        const converter = new StackConverter(new MockAppComponent('/tmp/foo/bar/does/not/exist'), manifest);
+        converter.convert(new Set());
+        const subnet = converter.resources.get('other')?.resource as native.ec2.Subnet;
+        const cidrBlock = await promiseOf(subnet.ipv6CidrBlock);
+        expect(cidrBlock).toEqual('cidr_ipv6AddressAttribute');
+    });
+
+    test('can convert multiple', async () => {
+        const manifest = new StackManifest({
+            id: 'stack',
+            templatePath: 'test/stack',
+            metadata: {
+                'stack/vpc': 'vpc',
+                'stack/cidr': 'cidr',
+                'stack/other': 'other',
+                'stack/vpc2': 'vpc2',
+                'stack/cidr2': 'cidr2',
+                'stack/other2': 'other2',
+            },
+            tree: {
+                path: 'stack',
+                id: 'stack',
+                children: {
+                    vpc: {
+                        id: 'vpc',
+                        path: 'stack/vpc',
+                        attributes: {
+                            'aws:cdk:cloudformation:type': 'AWS::EC2::VPC',
+                        },
+                    },
+                    cidr: {
+                        id: 'cidr',
+                        path: 'stack/cidr',
+                        attributes: {
+                            'aws:cdk:cloudformation:type': 'AWS::EC2::VPCCidrBlock',
+                        },
+                    },
+                    other: {
+                        id: 'other',
+                        path: 'stack/other',
+                        attributes: {
+                            'aws:cdk:cloudformation:type': 'AWS::EC2::Subnet',
+                        },
+                    },
+                    vpc2: {
+                        id: 'vpc2',
+                        path: 'stack/vpc2',
+                        attributes: {
+                            'aws:cdk:cloudformation:type': 'AWS::EC2::VPC',
+                        },
+                    },
+                    cidr2: {
+                        id: 'cidr2',
+                        path: 'stack/cidr2',
+                        attributes: {
+                            'aws:cdk:cloudformation:type': 'AWS::EC2::VPCCidrBlock',
+                        },
+                    },
+                    other2: {
+                        id: 'other2',
+                        path: 'stack/other2',
+                        attributes: {
+                            'aws:cdk:cloudformation:type': 'AWS::EC2::Subnet',
+                        },
+                    },
+                },
+                constructInfo: {
+                    fqn: 'aws-cdk-lib.Stack',
+                    version: '2.149.0',
+                },
+            },
+            template: {
+                Resources: {
+                    vpc: {
+                        Type: 'AWS::EC2::VPC',
+                        Properties: {},
+                    },
+                    cidr: {
+                        Type: 'AWS::EC2::VPCCidrBlock',
+                        Properties: {
+                            VpcId: { Ref: 'vpc' },
+                            Ipv6CidrBlock: 'cidr_ipv6AddressAttribute',
+                        },
+                    },
+                    other: {
+                        Type: 'AWS::EC2::Subnet',
+                        Properties: {
+                            Ipv6CidrBlock: { 'Fn::Select': [0, { 'Fn::GetAtt': ['vpc', 'Ipv6CidrBlocks'] }] },
+                        },
+                    },
+                    vpc2: {
+                        Type: 'AWS::EC2::VPC',
+                        Properties: {},
+                    },
+                    cidr2: {
+                        Type: 'AWS::EC2::VPCCidrBlock',
+                        Properties: {
+                            VpcId: { Ref: 'vpc2' },
+                            Ipv6CidrBlock: 'cidr_ipv6AddressAttribute_2',
+                        },
+                    },
+                    other2: {
+                        Type: 'AWS::EC2::Subnet',
+                        Properties: {
+                            Ipv6CidrBlock: { 'Fn::Select': [0, { 'Fn::GetAtt': ['vpc2', 'Ipv6CidrBlocks'] }] },
+                        },
+                    },
+                },
+            },
+            dependencies: [],
+        });
+        const converter = new StackConverter(new MockAppComponent('/tmp/foo/bar/does/not/exist'), manifest);
+        converter.convert(new Set());
+        const subnet = converter.resources.get('other')?.resource as native.ec2.Subnet;
+        const cidrBlock = await promiseOf(subnet.ipv6CidrBlock);
+        expect(cidrBlock).toEqual('cidr_ipv6AddressAttribute');
+        const subnet2 = converter.resources.get('other2')?.resource as native.ec2.Subnet;
+        const cidrBlock2 = await promiseOf(subnet2.ipv6CidrBlock);
+        expect(cidrBlock2).toEqual('cidr_ipv6AddressAttribute_2');
+    });
+});
+
 function createUrn(resource: string, logicalId: string): string {
     return `urn:pulumi:stack::project::cdk:construct:aws-cdk-lib/aws_s3:${resource}$aws-native:s3:${resource}::${logicalId}`;
 }
diff --git a/tests/graph.test.ts b/tests/graph.test.ts
index 42c7259..199b09c 100644
--- a/tests/graph.test.ts
+++ b/tests/graph.test.ts
@@ -188,7 +188,7 @@ describe('GraphBuilder', () => {
             },
         ],
     ])('Parses the graph correctly', (graph, path, expected) => {
-        const actual = graph.find((node) => node.construct.path === path);
+        const actual = graph.nodes.find((node) => node.construct.path === path);
         expect(actual).toBeDefined();
         expect(actual!.logicalId).toEqual(expected.logicalId);
         expect(actual!.resource).toEqual(expected.resource);
@@ -225,7 +225,7 @@ describe('GraphBuilder', () => {
             }),
         ],
     ])('adds edge for %s', (_name, stackManifest) => {
-        const graph = GraphBuilder.build(stackManifest);
+        const graph = GraphBuilder.build(stackManifest).nodes;
         expect(graph[1].construct.path).toEqual('stack/resource-1');
         expect(edgesToArray(graph[1].incomingEdges)).toEqual(['stack/resource-2']);
         expect(edgesToArray(graph[1].outgoingEdges)).toEqual(['stack']);
@@ -235,6 +235,163 @@ describe('GraphBuilder', () => {
     });
 });
 
+test('vpc with ipv6 cidr block', () => {
+    const nodes = GraphBuilder.build(
+        new StackManifest({
+            id: 'stack',
+            templatePath: 'test/stack',
+            metadata: {
+                'stack/vpc': 'vpc',
+                'stack/cidr': 'cidr',
+                'stack/other': 'other',
+            },
+            tree: {
+                path: 'stack',
+                id: 'stack',
+                children: {
+                    vpc: {
+                        id: 'vpc',
+                        path: 'stack/vpc',
+                        attributes: {
+                            'aws:cdk:cloudformation:type': 'AWS::EC2::VPC',
+                        },
+                    },
+                    cidr: {
+                        id: 'cidr',
+                        path: 'stack/cidr',
+                        attributes: {
+                            'aws:cdk:cloudformation:type': 'AWS::EC2::VPCCidrBlock',
+                        },
+                    },
+                    other: {
+                        id: 'other',
+                        path: 'stack/other',
+                        attributes: {
+                            'aws:cdk:cloudformation:type': 'AWS::Other::Resource',
+                        },
+                    },
+                },
+                constructInfo: {
+                    fqn: 'aws-cdk-lib.Stack',
+                    version: '2.149.0',
+                },
+            },
+            template: {
+                Resources: {
+                    vpc: {
+                        Type: 'AWS::EC2::VPC',
+                        Properties: {},
+                    },
+                    cidr: {
+                        Type: 'AWS::EC2::VPCCidrBlock',
+                        Properties: {
+                            VpcId: { Ref: 'vpc' },
+                        },
+                    },
+                    other: {
+                        Type: 'AWS::Other::Resource',
+                        Properties: {
+                            SomeProp: { 'Fn::Select': [0, { 'Fn::GetAtt': ['vpc', 'Ipv6CidrBlocks'] }] },
+                        },
+                    },
+                },
+            },
+            dependencies: [],
+        }),
+    ).nodes;
+    expect(nodes[0].construct.type).toEqual('aws-cdk-lib:Stack');
+    expect(nodes[1].construct.type).toEqual('VPC');
+    expect(nodes[2].construct.type).toEqual('VPCCidrBlock');
+    expect(nodes[2].incomingEdges.size).toEqual(1);
+    expect(nodes[3].construct.type).toEqual('Resource');
+
+    // The other resource should have it's edge swapped to the cidr resource
+    expect(Array.from(nodes[2].incomingEdges.values())[0].logicalId).toEqual('other');
+    expect(Array.from(nodes[3].outgoingEdges.values())[1].logicalId).toEqual('cidr');
+});
+
+test('vpc with multiple ipv6 cidr blocks fails', () => {
+    expect(() => {
+        GraphBuilder.build(
+            new StackManifest({
+                id: 'stack',
+                templatePath: 'test/stack',
+                metadata: {
+                    'stack/vpc': 'vpc',
+                    'stack/cidr': 'cidr',
+                    'stack/cidr2': 'cidr2',
+                    'stack/other': 'other',
+                },
+                tree: {
+                    path: 'stack',
+                    id: 'stack',
+                    children: {
+                        vpc: {
+                            id: 'vpc',
+                            path: 'stack/vpc',
+                            attributes: {
+                                'aws:cdk:cloudformation:type': 'AWS::EC2::VPC',
+                            },
+                        },
+                        cidr: {
+                            id: 'cidr',
+                            path: 'stack/cidr',
+                            attributes: {
+                                'aws:cdk:cloudformation:type': 'AWS::EC2::VPCCidrBlock',
+                            },
+                        },
+                        cidr2: {
+                            id: 'cidr2',
+                            path: 'stack/cidr2',
+                            attributes: {
+                                'aws:cdk:cloudformation:type': 'AWS::EC2::VPCCidrBlock',
+                            },
+                        },
+                        other: {
+                            id: 'other',
+                            path: 'stack/other',
+                            attributes: {
+                                'aws:cdk:cloudformation:type': 'AWS::Other::Resource',
+                            },
+                        },
+                    },
+                    constructInfo: {
+                        fqn: 'aws-cdk-lib.Stack',
+                        version: '2.149.0',
+                    },
+                },
+                template: {
+                    Resources: {
+                        vpc: {
+                            Type: 'AWS::EC2::VPC',
+                            Properties: {},
+                        },
+                        cidr: {
+                            Type: 'AWS::EC2::VPCCidrBlock',
+                            Properties: {
+                                VpcId: { Ref: 'vpc' },
+                            },
+                        },
+                        cidr2: {
+                            Type: 'AWS::EC2::VPCCidrBlock',
+                            Properties: {
+                                VpcId: { Ref: 'vpc' },
+                            },
+                        },
+                        other: {
+                            Type: 'AWS::Other::Resource',
+                            Properties: {
+                                SomeProp: { 'Fn::Select': [0, { 'Fn::GetAtt': ['vpc', 'Ipv6CidrBlocks'] }] },
+                            },
+                        },
+                    },
+                },
+                dependencies: [],
+            }),
+        ).nodes;
+    }).toThrow(/VPC vpc already has a VPCCidrBlock/);
+});
+
 test('pulumi resource type name fallsback when fqn not available', () => {
     const bucketId = 'example-bucket';
     const policyResourceId = 'Policy';
@@ -308,7 +465,7 @@ test('pulumi resource type name fallsback when fqn not available', () => {
             },
             dependencies: [],
         }),
-    );
+    ).nodes;
 
     expect(nodes[0].construct.type).toEqual('aws-cdk-lib:Stack');
     expect(nodes[1].construct.type).toEqual(bucketId);