AWS CDK: 6 Things You Need To Know
Tags: aws, awscdk, cdk, serverless
Published: 2022-01-27 21:00:38

The AWS Cloud Development Kit (CDK) has quickly become my preferred tool for defining AWS infrastructure. I built a project recently that would have been significantly more difficult with any other tool because it needed to span multiple AWS accounts, multiple AWS regions, and deploy several stacks of resources across them.

While the [CDK docs](https://docs.aws.amazon.com/cdk/v2/guide/home.html) are pretty good, and [the CDK Book](https://www.thecdkbook.com) is a great way to learn the basics, here are some important features I have learned along the way.

## Direct CloudFormation, a.k.a. Escape Hatches

CDK includes several 'level 2' constructs which are excellent: they provide sane defaults, improved autocompletion, boilerplate, and glue logic built-in. However, most AWS services off-the-beaten path don't have them yet, so you need to be prepared to effectively use 'level 1' constructs. These translate directly to CloudFormation resources, and include the full capability that CloudFormation does. This is huge, as you can confidently start a CDK project knowing that, at minimum, you'll have level 1 constructs for everything supported by CloudFormation.

Level 1 constructs will begin with `Cfn`, and you should refer to the [CloudFormation resource reference](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-template-resource-type-ref.html) when providing parameters, as there is no meaningful autocomplete support.

To pass references between these resources, use `.ref` to pass the ARN that will be generated at deploy-time. 

Here's an example, creating a Systems Manager Maintenance Window to run a Command Document on a schedule:

```
with open('example/example_ssm_document.json', 'r') as document_content:
    document_content = json.load(document_content)

document = ssm.CfnDocument(self, "ExampleCommandDocument",
                            content=document_content,
                            document_format="JSON",
                            document_type="Command",
                            )

window = ssm.CfnMaintenanceWindow(self, "ExampleWindow",
                                    allow_unassociated_targets=True,
                                    cutoff=0,
                                    name="ExampleWindow",
                                    duration=1,
                                    schedule='rate(1 hour)'
                                    )

task = ssm.CfnMaintenanceWindowTask(self, "ExampleTask",
                                    window_id=window.ref,
                                    max_concurrency='1',
                                    max_errors='1',
                                    priority=1,
                                    targets=[{
                                        'key': 'InstanceIds',
                                        'values': ['i-xxxxxxxxxxxxxx']
                                    }],
                                    task_arn=document.ref,
                                    task_type='RUN_COMMAND',
                                    )
```

## Custom Resources
In the rare case you need to define a resource that isn't supported by CloudFormation, you can still automate it using Custom Resources. Most of the time, these will just be single AWS API calls, and CDK has an awesome construct just for that: `AwsCustomResource`. You provide the service, API action, and parameters, and CDK will create and invoke a Lambda function accordingly. But here are some tricks that aren't immediately obvious:

1. `AwsCustomResource` uses the _JavaScript_ SDK, so you have to provide the services, actions, and parameters using that specification for spelling/casing/etc. Refer to the [JavaScript SDK](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/) for those.
2. The `physical_resource_id` parameter needs a verification token from the API response, which is easy to retrieve but wasn't intuitive: `PhysicalResourceId.from_response("VerificationToken")`
3. You can pass parameters directly, but you can't dynamically retrieve them, for example a secret from Secrets Manager. This can be a complete killer! I tried to create an AD Connector, and of course don't want to embed a password into the code, but attempting to retrieve it as below **does not work**.

```
AwsCustomResource(self, id='ADConnector',
            policy=AwsCustomResourcePolicy.from_sdk_calls(
                resources=AwsCustomResourcePolicy.ANY_RESOURCE),
            on_create=AwsSdkCall(
                service="DirectoryService",
                action="connectDirectory",
                parameters={
                    "Name": ad_fqdn,
                    "Password": cdk.SecretValue.secrets_manager(secret_id=ad_service_account_secret_arn, json_field="password").to_string(),
                    "ConnectSettings": {
                        "VpcId": vpc_id,
                        "SubnetIds": subnet_ids,
                        "CustomerDnsIps": customer_dns_ips,
                        "CustomerUserName": cdk.SecretValue.secrets_manager(secret_id=ad_service_account_secret_arn, json_field="username").to_string(),
                    },
                    "Size": "Small",
                },
                physical_resource_id=PhysicalResourceId.from_response(
                    "VerificationToken")
            ))
```

Something on my #awswishlist would be custom resources backed by the new Step Functions SDK support, instead of a Lambda function. This could include a series of API calls that pass values between them.

## Import Existing Resources
In many cases, you'll need to get information about resources defined outside your application, and you can import them quite easily. A common one is where networks have been created by some other mechanism, but you need to deploy into them. Importing a VPC is straightforward using `ec2.Vpc.from_lookup`, but importing subnets by id wasn't immediately obvious. The answer is to use `ec2.SubnetSelection` as easy as below:
```
SubnetSelection(subnet_filters=[SubnetFilter.by_ids(subnet_ids)])
```

## Create and Use a Secrets from Secrets Manager
When handling secrets, you have to be extra careful to avoid printing them into the template where they could be inadvertently accessed. To generate a secret securely, use `SecretStringGenerator` from Secrets Manager, and to pass that into a resource, refer to the secret using `SecretValue.secrets_manager`. For example, the below sample creates a Directory Service Microsoft AD with a generated admin password:

```
ad_admin_password = secretsmanager.Secret(
    self, "ADAdminPassword",
    secret_name="ad-admin-password",
    generate_secret_string=secretsmanager.SecretStringGenerator(),
    removal_policy=cdk.RemovalPolicy.RETAIN)

managed_ad = directoryservice.CfnMicrosoftAD(self, "ManagedAD",
    name=name,
    password=cdk.SecretValue.secrets_manager(
        ad_admin_password.secret_arn).to_string(),
    vpc_settings=directoryservice.CfnMicrosoftAD.VpcSettingsProperty(
        subnet_ids=subnet_ids,
        vpc_id=vpc_id
    ),
    create_alias=False,
    edition=edition,
    enable_sso=False,
    )
```

## Pass Values Between Stacks
One of the biggest reasons to use CDK is the ability to handle multiple CloudFormation stacks in one application and pass values between them.

To do this, define a `CfnOutput` with an export name that you will reference in another Stack. CDK is smart enough to identify this dependency and order stack deployment accordingly, awesome!

For example, let's say we want to be able to access the alias or DNS addresses generated by the above Managed AD:
```
cdk.CfnOutput(self, "ManagedADId",
                value=managed_ad.attr_alias,
                export_name="ManagedADId")
cdk.CfnOutput(self, "ManagedADDnsIpAddresses",
                value=cdk.Fn.join(',', managed_ad.attr_dns_ip_addresses),
                export_name="ManagedADDnsIpAddresses")
```
Notice that we use `cdk.Fn.join` to combine multiple DNS addresses into a single value.

## Create Multiple Similar Resources with Unique IDs
This is a dangerous one, but powerful if used carefully. You always want infrastructure definition to be *declarative*, and using loops risks breaking that if the inputs are dynamic. 

That said, let's say you want a pipeline to deploy a set of stacks into multiple regions. You can define a stage with those stacks, and put multiple stages into a single wave to be deployed in parallel:

```
for region in constants.REGIONS:
        pipeline_wave.add_stage(
            ExampleStage(self,
                id="ExampleStage-{}".format(
                    region.region),
                env=cdk.Environment(
                    account=constants.EXAMPLE_ACCOUNT_ID,
                    region=region.region),
        )
```
We've defined a python [dataclass](https://docs.python.org/3/library/dataclasses.html) with values needed in each region, and we can loop through it during the CDK Pipeline definition. Notice that the `id` must be unique, so it includes the region name.

## Conclusion
I am optimistic about the future of AWS CDK, and I'll be using it for new projects until someone convinces me otherwise. At minimum, it's a better way to write CloudFormation. But it also empowers you well beyond that, and is just a pleasure to use.