diff --git a/integration/ec2/Pulumi.yaml b/integration/ec2/Pulumi.yaml new file mode 100644 index 00000000..d876e339 --- /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 00000000..b8d3f471 --- /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 00000000..888282a9 --- /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.5.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 00000000..eac442cb --- /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 ff2caa76..636e84ee 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.5.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 21cd6115..67ba32cc 100644 --- a/src/converters/app-converter.ts +++ b/src/converters/app-converter.ts @@ -94,6 +94,7 @@ export class StackConverter extends ArtifactConverter { private readonly cdkStack: cdk.Stack; private _stackResource?: CdkConstruct; + private _graph: GraphBuilder; public get stackResource(): CdkConstruct { if (!this._stackResource) { @@ -105,10 +106,11 @@ export class StackConverter extends ArtifactConverter { constructor(host: AppComponent, readonly stack: StackManifest) { super(host); this.cdkStack = host.stacks[stack.id]; + this._graph = new GraphBuilder(stack); } public convert(dependencies: Set) { - const dependencyGraphNodes = GraphBuilder.build(this.stack); + const dependencyGraphNodes = this._graph.build(); // process parameters first because resources will reference them for (const [logicalId, value] of Object.entries(this.stack.parameters ?? {})) { @@ -359,7 +361,21 @@ 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 === this._graph.vpcNode?.logicalId && + attributeName === 'Ipv6CidrBlocks' && + this._graph.vpcCidrBlockNode?.logicalId + ) { + return [this.resolveAtt(this._graph.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 b9156657..6b098bf7 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -115,15 +115,19 @@ export class GraphBuilder { // Map of resource logicalId to GraphNode. Allows for easy lookup by logicalId cfnElementNodes: Map; + // If the app has a VpcCidrBlock resource, this will be set to the GraphNode representing it + vpcCidrBlockNode?: GraphNode; + // If the app has a Vpc resource, this will be set to the GraphNode representing it + vpcNode?: GraphNode; + constructor(private readonly stack: StackManifest) { this.constructNodes = new Map(); this.cfnElementNodes = new Map(); } // 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 build(): GraphNode[] { + return this._build(); } /** @@ -163,6 +167,12 @@ export class GraphBuilder { `Something went wrong: resourceType ${resource.Type} does not equal CfnType ${cfnType}`, ); } + if (resource.Type === 'AWS::EC2::VPCCidrBlock') { + this.vpcCidrBlockNode = node; + } + if (resource.Type === 'AWS::EC2::VPC') { + this.vpcNode = node; + } } this.constructNodes.set(construct, node); if (tree.children) { @@ -285,9 +295,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 === this.vpcNode?.logicalId && + attributeName === 'Ipv6CidrBlocks' && + this.vpcCidrBlockNode?.logicalId + ) { + logicalId = this.vpcCidrBlockNode.logicalId; + } + this.addEdgeForRef(logicalId, source); break; + } case 'Fn::Sub': { const [template, vars] = diff --git a/tests/converters/app-converter.test.ts b/tests/converters/app-converter.test.ts index e76c8bbf..c6550c80 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,77 @@ 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: { + Ipv6AddressAttribute: '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'); + }); +}); + 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 42c72590..b3e28bd6 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -17,7 +17,7 @@ import { StackManifest } from '../src/assembly'; import { createStackManifest } from './utils'; describe('GraphBuilder', () => { - const nodes = GraphBuilder.build( + const nodes = new GraphBuilder( new StackManifest({ id: 'stack', templatePath: 'test/stack', @@ -95,7 +95,7 @@ describe('GraphBuilder', () => { }, dependencies: [], }), - ); + ).build(); test.each([ [ nodes, @@ -225,7 +225,7 @@ describe('GraphBuilder', () => { }), ], ])('adds edge for %s', (_name, stackManifest) => { - const graph = GraphBuilder.build(stackManifest); + const graph = new GraphBuilder(stackManifest).build(); 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']); @@ -234,11 +234,83 @@ describe('GraphBuilder', () => { expect(edgesToArray(graph[2].outgoingEdges)).toEqual(['stack', 'stack/resource-1']); }); }); +test('vpc with ipv6 cidr block', () => { + const nodes = new GraphBuilder( + 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: {}, + }, + other: { + Type: 'AWS::Other::Resource', + Properties: { + SomeProp: { 'Fn::Select': [0, { 'Fn::GetAtt': ['vpc', 'Ipv6CidrBlocks'] }] }, + }, + }, + }, + }, + dependencies: [], + }), + ).build(); + 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())[0].logicalId).toEqual('cidr'); +}); test('pulumi resource type name fallsback when fqn not available', () => { const bucketId = 'example-bucket'; const policyResourceId = 'Policy'; - const nodes = GraphBuilder.build( + const nodes = new GraphBuilder( new StackManifest({ id: 'stack', templatePath: 'test/stack', @@ -308,7 +380,7 @@ test('pulumi resource type name fallsback when fqn not available', () => { }, dependencies: [], }), - ); + ).build(); expect(nodes[0].construct.type).toEqual('aws-cdk-lib:Stack'); expect(nodes[1].construct.type).toEqual(bucketId); diff --git a/yarn.lock b/yarn.lock index 9fbaa233..bc7dc916 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.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@pulumi/aws-native/-/aws-native-1.5.0.tgz#ade142eebae2e2b44329b1811fbf9b24f8288a06" + integrity sha512-zgPrsGnYS1daHlOd3yyRhP/kmuYjRRnqjb4PIStPK3NDWXn46NA+CqV/SUHJzqQfSI8EQbkrJq95hw+WrZ/DAQ== dependencies: "@pulumi/pulumi" "^3.136.0"