Repository: pulumi/pulumi-cdk (ts)

Issue:
Missing AWS::Route53::* types
## Hello!
<!-- Please leave this section as-is, it's designed to help others in the community know how to interact with our GitHub issues. -->

- Vote on this issue by adding a 👍 reaction
- If you want to implement this feature, comment to let us know (we'll work with you on design, scheduling, etc.)

## Issue details

<!-- Enhancement requests are most helpful when they describe the problem you're having as well as articulating the potential solution you'd like to see built. -->

```[tasklist]
### Tasks
- [ ] AWS::Route53::RecordSet
```

No L2s yet so don't need to map
```
- [ ] AWS::Route53::RecordSetGroup
```

### Affected area/feature

<!-- If you know the specific area where this feature request would go (e.g. Automation API, the Pulumi Service, the Terraform bridge, etc.), feel free to put that area here.  -->


Pull request description:
Add support for Route53 records
This PR adds support for Route53 records (`AWS::Route53::RecordSet`) which is not currently supported by CCAPI.

I've also added integration tests for the Route53 constructs.

re #183, closes #177

Patch:
diff --git a/integration/ec2/index.ts b/integration/ec2/index.ts
index b8d3f47..4e9533b 100644
--- a/integration/ec2/index.ts
+++ b/integration/ec2/index.ts
@@ -3,11 +3,18 @@ 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';
+import { SecretValue, Size } from 'aws-cdk-lib/core';
 
 class Ec2Stack extends pulumicdk.Stack {
     constructor(app: pulumicdk.App, id: string, options?: pulumicdk.StackOptions) {
-        super(app, id, options);
+        super(app, id, {
+            ...options,
+            props: {
+                ...options?.props,
+                // required to avoid the Fn::FindInMap
+                env: { region: aws.config.region },
+            },
+        });
         const vpc = new ec2.Vpc(this, 'Vpc', {
             maxAzs: 2,
             ipProtocol: ec2.IpProtocol.DUAL_STACK,
@@ -77,6 +84,77 @@ class Ec2Stack extends pulumicdk.Stack {
             vpcEndpointServiceLoadBalancers: [nlb],
             allowedPrincipals: [new iam.ArnPrincipal('ec2.amazonaws.com')],
         });
+
+        // use getAmiOutput to lookup the AMI instead of ec2.LookupMachineImage
+        const ami = aws.ec2.getAmiOutput({
+            owners: ['amazon'],
+            mostRecent: true,
+            filters: [
+                {
+                    name: 'name',
+                    values: ['al2023-ami-2023.*.*.*.*-arm64'],
+                },
+            ],
+        });
+
+        const region = aws.config.requireRegion();
+        const machineImage = ec2.MachineImage.genericLinux({
+            [region]: pulumicdk.asString(ami.imageId),
+        });
+
+        const pg = new ec2.PlacementGroup(this, 'PlacementGroup', {});
+        const availabilityZone = pulumicdk.asString(
+            aws
+                .getAvailabilityZonesOutput({
+                    state: 'available',
+                    allAvailabilityZones: false,
+                    filters: [
+                        {
+                            name: 'opt-in-status',
+                            values: ['opt-in-not-required'],
+                        },
+                    ],
+                })
+                .apply((zones) => zones.names[0]),
+        );
+        const volume = new ec2.Volume(this, 'Volume', {
+            availabilityZone,
+            size: Size.gibibytes(10),
+        });
+        const instance = new ec2.Instance(this, 'Instance', {
+            vpc,
+            // smallest allowed for placement groups
+            instanceType: ec2.InstanceType.of(ec2.InstanceClass.M7G, ec2.InstanceSize.MEDIUM),
+            machineImage,
+            vpcSubnets: vpc.selectSubnets({ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }),
+            requireImdsv2: true,
+            placementGroup: pg,
+            blockDevices: [
+                {
+                    volume: ec2.BlockDeviceVolume.ebs(10, { volumeType: ec2.EbsDeviceVolumeType.GP3 }),
+                    deviceName: '/dev/sdf',
+                },
+            ],
+        });
+
+        volume.grantAttachVolumeByResourceTag(instance.grantPrincipal, [instance]);
+
+        const instanceProfile = new iam.InstanceProfile(this, 'InstanceProfile');
+        new ec2.LaunchTemplate(this, 'LaunchTemplate', {
+            blockDevices: [
+                {
+                    deviceName: '/dev/sdf',
+                    volume: ec2.BlockDeviceVolume.ebs(10, { volumeType: ec2.EbsDeviceVolumeType.GP3 }),
+                },
+            ],
+            requireImdsv2: true,
+            instanceType: ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.MICRO),
+            machineImage,
+            httpTokens: ec2.LaunchTemplateHttpTokens.REQUIRED,
+            httpPutResponseHopLimit: 1,
+            httpEndpoint: true,
+            instanceProfile,
+        });
     }
 }
 
diff --git a/integration/route53/Pulumi.yaml b/integration/route53/Pulumi.yaml
new file mode 100644
index 0000000..8b3ac1c
--- /dev/null
+++ b/integration/route53/Pulumi.yaml
@@ -0,0 +1,3 @@
+name: pulumi-aws-route53
+runtime: nodejs
+description: route53 integration test
diff --git a/integration/route53/index.ts b/integration/route53/index.ts
new file mode 100644
index 0000000..e10524a
--- /dev/null
+++ b/integration/route53/index.ts
@@ -0,0 +1,84 @@
+import * as ec2 from 'aws-cdk-lib/aws-ec2';
+import * as route53 from 'aws-cdk-lib/aws-route53';
+import * as pulumicdk from '@pulumi/cdk';
+import { Duration } from 'aws-cdk-lib/core';
+import { aws_elasticloadbalancingv2, aws_kms, aws_route53_targets } from 'aws-cdk-lib';
+
+class Route53Stack extends pulumicdk.Stack {
+    constructor(app: pulumicdk.App, id: string, options?: pulumicdk.StackOptions) {
+        super(app, id, options);
+        const kmsKey = new aws_kms.Key(this, 'Key', {
+            keySpec: aws_kms.KeySpec.ECC_NIST_P256,
+            keyUsage: aws_kms.KeyUsage.SIGN_VERIFY,
+            pendingWindow: Duration.days(7),
+        });
+        const zone = new route53.HostedZone(this, 'HostedZone', {
+            zoneName: 'pulumi-cdk.com',
+        });
+        zone.enableDnssec({
+            kmsKey,
+        });
+
+        new route53.TxtRecord(this, 'TxtRecord', {
+            zone,
+            values: ['somevalue'],
+            recordName: 'cdk-txt',
+        });
+
+        new route53.TxtRecord(this, 'TxtRecord2', {
+            zone,
+            values: ['hello'.repeat(52)],
+            recordName: 'cdk-txt-2',
+        });
+
+        new route53.CnameRecord(this, 'Cname', {
+            zone,
+            recordName: 'cdk-cname',
+            domainName: 'pulumi.com',
+            ttl: Duration.minutes(1),
+        });
+
+        new route53.ARecord(this, 'ARecord', {
+            zone,
+            recordName: 'cdk-a',
+            target: route53.RecordTarget.fromIpAddresses('1.2.3.4'),
+            geoLocation: route53.GeoLocation.continent(route53.Continent.NORTH_AMERICA),
+        });
+
+        const vpc = new ec2.Vpc(this, 'Vpc', {
+            maxAzs: 2,
+            ipAddresses: ec2.IpAddresses.cidr('10.0.0.0/16'),
+            natGateways: 0,
+            subnetConfiguration: [
+                {
+                    name: 'Isolated',
+                    subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
+                },
+            ],
+        });
+        const privateZone = new route53.PrivateHostedZone(this, 'PrivateHostedZone', {
+            zoneName: 'pulumi-cdk-private.com',
+            vpc,
+        });
+        const nlb = new aws_elasticloadbalancingv2.NetworkLoadBalancer(this, 'NLB', {
+            vpc,
+            vpcSubnets: vpc.selectSubnets({ subnetType: ec2.SubnetType.PRIVATE_ISOLATED }),
+        });
+        new route53.AaaaRecord(this, 'AaaaRecord1', {
+            recordName: 'nlb',
+            zone: privateZone,
+            target: route53.RecordTarget.fromAlias(new aws_route53_targets.LoadBalancerTarget(nlb)),
+            weight: 1,
+        });
+        new route53.AaaaRecord(this, 'AaaaRecord2', {
+            recordName: 'nlb',
+            zone: privateZone,
+            target: route53.RecordTarget.fromAlias(new aws_route53_targets.LoadBalancerTarget(nlb)),
+            weight: 2,
+        });
+    }
+}
+
+new pulumicdk.App('app', (scope: pulumicdk.App) => {
+    new Route53Stack(scope, 'teststack');
+});
diff --git a/integration/route53/package.json b/integration/route53/package.json
new file mode 100644
index 0000000..888282a
--- /dev/null
+++ b/integration/route53/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/route53/tsconfig.json b/integration/route53/tsconfig.json
new file mode 100644
index 0000000..eac442c
--- /dev/null
+++ b/integration/route53/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/src/aws-resource-mappings.ts b/src/aws-resource-mappings.ts
index 496b4fa..339b2bb 100644
--- a/src/aws-resource-mappings.ts
+++ b/src/aws-resource-mappings.ts
@@ -193,6 +193,65 @@ export function mapToAwsResource(
             return resources;
         }
 
+        case 'AWS::Route53::RecordSet': {
+            let records: string[] = props.resourceRecords;
+            if (props.type === 'TXT') {
+                // CDK has special handling for TXT records that conflicts with the Terraform provider's handling.
+                // 1. CDK wraps the value in double quotes, which Terraform does as well. We need to remove the quotes that
+                //    CDK adds otherwise we get double quotes
+                // 2. CDK splits the value into multiple records if it exceeds 255 characters. Terraform does not do this so we need to.
+                //
+                //           (user)                 (cdk)                (terraform)
+                //    e.g. "hello...hello" => '"hello...""hello"'    ["hello...", "hello"]
+                records = records.flatMap((r) => r.split('""').flatMap((record) => record.replace(/"/g, '')));
+            }
+            return new aws.route53.Record(
+                logicalId,
+                {
+                    zoneId: props.hostedZoneId,
+                    name: props.name,
+                    type: props.type,
+                    records,
+                    ttl: props.ttl,
+                    aliases: props.aliasTarget
+                        ? [
+                              {
+                                  name: props.aliasTarget.dnsName,
+                                  zoneId: props.aliasTarget.hostedZoneId,
+                                  evaluateTargetHealth: props.aliasTarget.evaluateTargetHealth ?? false,
+                              },
+                          ]
+                        : undefined,
+                    healthCheckId: props.healthCheckId,
+                    setIdentifier: props.setIdentifier,
+                    cidrRoutingPolicy: props.cidrRoutingConfig,
+                    failoverRoutingPolicies: props.failover ? [{ type: props.failover }] : undefined,
+                    weightedRoutingPolicies: props.weight ? [{ weight: props.weight }] : undefined,
+                    geoproximityRoutingPolicy: props.geoProximityLocation
+                        ? {
+                              bias: props.geoProximityLocation.bias,
+                              awsRegion: props.geoProximityLocation.awsRegion,
+                              localZoneGroup: props.geoProximityLocation.localZoneGroup,
+                              coordinates: props.geoProximityLocation.coordinates
+                                  ? [props.geoProximityLocation.coordinates]
+                                  : undefined,
+                          }
+                        : undefined,
+                    geolocationRoutingPolicies: props.geoLocation
+                        ? [
+                              {
+                                  country: props.geoLocation.countryCode,
+                                  continent: props.geoLocation.continentCode,
+                                  subdivision: props.geoLocation.subdivisionCode,
+                              },
+                          ]
+                        : undefined,
+                    multivalueAnswerRoutingPolicy: props.multiValueAnswer,
+                },
+                options,
+            );
+        }
+
         default:
             return undefined;
     }


Test patch:
diff --git a/integration/examples_nodejs_test.go b/integration/examples_nodejs_test.go
index 192e378..2059c84 100644
--- a/integration/examples_nodejs_test.go
+++ b/integration/examples_nodejs_test.go
@@ -50,6 +50,20 @@ func TestEc2(t *testing.T) {
 	integration.ProgramTest(t, &test)
 }
 
+func TestRoute53(t *testing.T) {
+	test := getJSBaseOptions(t).
+		With(integration.ProgramTestOptions{
+			Dir: filepath.Join(getCwd(t), "route53"),
+			Config: map[string]string{
+				// This test has to be run in us-east-1 for DNSSEC
+				"aws:region":        "us-east-1",
+				"aws-native:region": "us-east-1",
+			},
+    })
+
+	integration.ProgramTest(t, &test)
+}
+
 func TestKms(t *testing.T) {
 	test := getJSBaseOptions(t).
 		With(integration.ProgramTestOptions{
diff --git a/tests/aws-resource-mappings.test.ts b/tests/aws-resource-mappings.test.ts
index 0a5add7..e5b8d02 100644
--- a/tests/aws-resource-mappings.test.ts
+++ b/tests/aws-resource-mappings.test.ts
@@ -1,5 +1,5 @@
-import { CfnResource, Stack } from 'aws-cdk-lib/core';
 import { mapToAwsResource } from '../src/aws-resource-mappings';
+import * as route53 from 'aws-cdk-lib/aws-route53';
 import * as aws from '@pulumi/aws';
 
 jest.mock('@pulumi/aws', () => {
@@ -33,6 +33,11 @@ jest.mock('@pulumi/aws', () => {
                 return {};
             }),
         },
+        route53: {
+            Record: jest.fn().mockImplementation(() => {
+                return {};
+            }),
+        },
     };
 });
 
@@ -270,4 +275,95 @@ describe('AWS Resource Mappings', () => {
             {},
         );
     });
+
+    test('maps route53.Record', () => {
+        // GIVEN
+        const cfnType = 'AWS::Route53::RecordSet';
+        const logicalId = 'my-resource';
+        const cfnProps = {
+            HostedZoneId: 'zone-id',
+            Name: 'example.com',
+            Type: 'A',
+            TTL: 900,
+            ResourceRecords: ['192.0.2.99'],
+            AliasTarget: {
+                DNSName: 'example.com',
+                HostedZoneId: 'zone-id',
+                EvaluateTargetHealth: true,
+            },
+            HealthCheckId: 'health-check-id',
+            SetIdentifier: 'set-identifier',
+            CidrRoutingConfig: {
+                CollectionId: 'collection-id',
+                LocationName: 'location-name',
+            },
+            Failover: 'PRIMARY',
+            Weight: 1,
+            GeoProximityLocation: {
+                Bias: 'bias',
+                AWSRegion: 'region',
+                LocalZoneGroup: 'group',
+                Coordinates: {
+                    Latitude: 0,
+                    Longitude: 0,
+                },
+            },
+            GeoLocation: {
+                ContinentCode: 'code',
+                CountryCode: 'code',
+                SubdivisionCode: 'code',
+            },
+            MultiValueAnswer: true,
+        };
+
+        // WHEN
+        mapToAwsResource(logicalId, cfnType, cfnProps, {});
+
+        // THEN
+        expect(aws.route53.Record).toHaveBeenCalledWith(
+            logicalId,
+            expect.objectContaining({
+                zoneId: 'zone-id',
+                name: 'example.com',
+                type: 'A',
+                records: ['192.0.2.99'],
+                ttl: 900,
+                aliases: [
+                    {
+                        name: 'example.com',
+                        zoneId: 'zone-id',
+                        evaluateTargetHealth: true,
+                    },
+                ],
+                healthCheckId: 'health-check-id',
+                setIdentifier: 'set-identifier',
+                cidrRoutingPolicy: {
+                    collectionId: 'collection-id',
+                    locationName: 'location-name',
+                },
+                failoverRoutingPolicies: [{ type: 'PRIMARY' }],
+                weightedRoutingPolicies: [{ weight: 1 }],
+                geoproximityRoutingPolicy: {
+                    bias: 'bias',
+                    awsRegion: 'region',
+                    localZoneGroup: 'group',
+                    coordinates: [
+                        {
+                            latitude: 0,
+                            longitude: 0,
+                        },
+                    ],
+                },
+                geolocationRoutingPolicies: [
+                    {
+                        continent: 'code',
+                        country: 'code',
+                        subdivision: 'code',
+                    },
+                ],
+                multivalueAnswerRoutingPolicy: true,
+            }),
+            {},
+        );
+    });
 });
diff --git a/tests/cdk-resource.test.ts b/tests/cdk-resource.test.ts
index 9eb520d..7ca439f 100644
--- a/tests/cdk-resource.test.ts
+++ b/tests/cdk-resource.test.ts
@@ -1,3 +1,4 @@
+import * as route53 from 'aws-cdk-lib/aws-route53';
 import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
 import { TableArgs } from '@pulumi/aws-native/dynamodb';
 import { Key } from 'aws-cdk-lib/aws-kms';
@@ -5,14 +6,17 @@ import { setMocks, testApp } from './mocks';
 import { MockResourceArgs } from '@pulumi/pulumi/runtime';
 import { Construct } from 'constructs';
 
+let resources: MockResourceArgs[] = [];
+beforeAll(() => {
+    resources = [];
+    setMocks(resources);
+});
+
 describe('CDK Construct tests', () => {
     // DynamoDB table was previously mapped to the `aws` provider
     // otherwise this level of testing wouldn't be necessary.
     // We also don't need to do this type of testing for _every_ resource
     test('dynamodb table', async () => {
-        const resources: MockResourceArgs[] = [];
-        setMocks(resources);
-
         await testApp((scope: Construct) => {
             const key = Key.fromKeyArn(scope, 'key', 'arn:aws:kms:us-west-2:123456789012:key/abcdefg');
             const table = new dynamodb.Table(scope, 'Table', {
@@ -89,4 +93,20 @@ describe('CDK Construct tests', () => {
             ],
         } as TableArgs);
     });
+
+    test('route53 long text records are split', async () => {
+        await testApp((scope: Construct) => {
+            const zone = new route53.PublicHostedZone(scope, 'HostedZone', {
+                zoneName: 'pulumi-cdk.com',
+            });
+            new route53.TxtRecord(scope, 'TxtRecord2', {
+                zone,
+                values: ['hello'.repeat(52)],
+                recordName: 'cdk-txt-2',
+            });
+        });
+        const txt = resources.find((res) => res.type === 'aws:route53/record:Record');
+        expect(txt).toBeDefined();
+        expect(txt?.inputs.records).toEqual(['hello'.repeat(51), 'hello']);
+    });
 });