Mastering the Art of Developing Flexible and Stable AWS CDK Libraries

The AWS CDK offers an elegant way to share code across teams. However, when developing shareable AWS CDK libraries, it is crucial to consider the potential impact on downstream projects if the library is modified or misused by downstream developers. This blog post focuses on essential best practices for creating resilient AWS CDK libraries that prevent project breakdowns and ensure seamless integration in downstream projects.

Avoid Explicitly Specifying Name Attributes (Mostly)

It is generally advisable to avoid exposing "name" attributes for AWS components, such as the roleName of the Role component. By allowing the AWS CDK to dynamically manage names instead of explicitly specifying them, you can ensure smoother redeployment and prevent conflicts when modifying stacks or constructs.

The AWS CDK incorporates an automatic deployment mechanism that generates unique names for modified components, enabling hassle-free redeployment. However, assigning a specific name to a component leads to a deployment approach where the CDK attempts to create a new component with that exact name before deleting the old one. This method becomes problematic when conflicts arise, resulting in deployment failures.

For example, let's consider the usage of the Role component with a specific roleName set as blah. When deploying a new version of blah, the CDK tries to assign the new component the same name, blah, as specified. However, due to the conflict, the CDK fails to deploy the changes.

To illustrate how to mitigate this problem, let's examine a concrete example. The following code demonstrates a class that creates a route53 zone admin with limited privileges, allowing the listed users to perform specific actions. To ensure that future changes to this class won't impact downstream users, we prevent the passing of the managedPolicyName to the ManagedPolicy constructor, from a user of the Route53ZoneAdmin while allowing the user of the class to specify only the outermost ID.

interface Route53AdminProps extends CommonConstructProps
{
hostedZoneIds: string[]
users: IUser[]
}

/**
* Creates a group/policy that gives the defined users
* route53:ListResourceRecordSets and route53:ChangeResourceRecordSets access
* to the given zone id.
*/

export class Route53ZoneAdmin extends Construct
{
constructor(scope: Construct, id: string, props: Route53AdminProps)
{
super(scope, id);
const r53AdminGroup = new aws_iam.Group(this, 'group')
props.users.forEach((user) => r53AdminGroup.addUser(user))

const zoneIds = props.hostedZoneIds.map(
zoneId => `arn:aws:route53:::hostedzone/${zoneId}`)

new ManagedPolicy(this, 'policy', {
groups: [r53AdminGroup],
statements: [
new PolicyStatement({
actions: [
"route53:ChangeResourceRecordSets",
"route53:ListResourceRecordSets"
],
resources: zoneIds
})
]
})
}
}

Take a close look at the code above, and consider what happens when you instantiate that class as follows...

new Route53ZoneAdmin(this, 'r53admin');

In the code above, note that when instantiating the Route53ZoneAdmin class, the generated name will be in the form r53adminpolicy31195993, constructed from the r53admin ID and the policy ID passed into the ManagedPolicy constructor. By relying on the CDK to generate the name from IDs instead of allowing the direct pass-in of managedPolicyName, the name can be dynamically generated by the CDK. If, on the other hand, we allowed managedPolicyName to be passed in, it would require deleting the original Route53ZoneAdmin entirely before deploying a new one, so as to avoid the name conflict.

By following this practice, you can ensure smoother deployments, prevent conflicts, and promote seamless integration when making modifications to AWS CDK libraries used by downstream projects.

Abstract Away from the CDK

It is crucial to expose only the essential features that clients of your library require access to. Ideally, this should be done through your own abstraction layer, where you take the passed-in data and convert it into a format compatible with the AWS CDK. A great illustration of this principle is demonstrated when accepting a list of zoneIds to apply a policy to:

const zoneIds = props.hostedZoneIds.map(
zoneId => `arn:aws:route53:::hostedzone/${zoneId}`)
new ManagedPolicy(this, 'policy', {
groups: [r53AdminGroup],
statements: [
new PolicyStatement({
actions: [
"route53:ChangeResourceRecordSets",
"route53:ListResourceRecordSets"
],
resources: zoneIds
})
]
})

Observe how the zone ids are mapped to a structure compatible with AWS. The user of the class is shielded from the underlying AWS details, as we abstract away the complexity. However, we should ensure consistency by extending this abstraction to the list of IUser as well.

By abstracting away from the CDK and providing a well-designed interface, you create a cleaner and more intuitive experience for the clients of your AWS CDK library. This approach promotes code simplicity, enhances maintainability, and reduces the risk of exposing unnecessary implementation details.

Managing Flexibility for Some Use-Cases

In certain cases, it may be necessary to provide flexibility in your library, even if misuse could potentially break downstream projects. However, it is crucial to thoroughly document such cases to ensure proper usage.

One example where flexibility is crucial is when dealing with cross-account roles. These roles are unique in that they are often in regular use, and changing their names can potentially disrupt the users of these roles. Because of this risk, it is important to allow the passing of a specific roleName, providing clients with the ability to customize the role's name as needed. However, it is imperative to clearly document the potential impact on users of the cross-account role and recommend careful consideration when passing the roleName parameter. By acknowledging the specific considerations of cross-account roles and providing the necessary documentation, you empower users to make informed decisions while maintaining the desired flexibility.

export class CrossAccountRole extends Construct
{
readonly role: Role;

constructor(scope: Construct, id: string, accountIds: string[],
name?: string)
{
super(scope, id);
let mainAccountId = accountIds[0];
let remainingAccountIds = accountIds.slice(1)
let roleName = name ?? undefined;
let role = new Role(this, 'cross-account-role', {
assumedBy: new AccountPrincipal(mainAccountId),
roleName,
description: `A cross account role for ${id}`
})
if (remainingAccountIds.length > 0)
{
let accountPrincipals = remainingAccountIds.map(
id => new AccountPrincipal(id))
role.assumeRolePolicy?.addStatements(
new PolicyStatement({
principals: accountPrincipals,
actions: ["sts:AssumeRole"]
}))
}

this.role = role
}
}

To change this role while ensuring a smooth transition, the following steps need to be followed:

  1. Create a new role with a different name.
  2. Delete the old role.
  3. Update the new role to use the old name, or transition to using the new name.

By clearly documenting these steps and considerations, users of your library will have the necessary guidance to handle flexibility requirements appropriately.

When incorporating flexibility into your AWS CDK libraries, it is important to strike a balance between providing customization options and avoiding potential pitfalls. By offering well-documented guidelines, you empower downstream users to utilize your library effectively while minimizing the risk of unintended consequences.

Short Tips

Here are some short tips to keep in mind when working with AWS CDK libraries:

  • Use short IDs: Short IDs not only enhance readability but also improve the flow in the tree view of CloudFormation stacks.
  • Ensure unique IDs within the context:
    • For outer components within the same stack, each must have a unique ID.
    • For subcomponents at the same level, each must have a unique ID.
  • Encapsulate component details: Whenever possible, encapsulate the implementation details of your component, making them inaccessible from outside. However, consider exposing specific subcomponents that users may need to utilize. For example, the CrossAccountRole focuses on creating roles accessible outside the current account, while the details of role permissions remain the responsibility of the caller. In such cases, exposing the "role" allows for reuse across different types of CrossAccountRoles.

By following these tips, you can enhance the readability, maintainability, and reusability of your AWS CDK libraries while promoting clear boundaries between components.

Original content written by Trenton D. Adams, with assistance from ChatGPT for clarity of wording.