Conventional Use of AWS CDK
Omid Eidivandi
Posted on November 5, 2024
Infrastructure as code, a principal rule of agility and reliability, helps deliver configurable software by combining all different pieces into a single asset, such as Software code, Configuration, and infrastructure. However, this approach introduces some complexities and can become a bottleneck against one of the principal goals of IaC, agility.
AWS CDK, as an abstraction layer, offers more flexibility using code by overcoming the difficulties of structured cloud formation templating. However, this allowance of autonomy and flexibility can become an obstacle in the long term. Often, this becomes frustrating when applying some conventions at the enterprise level, when teams must change all stacks to respect some standard or convention. Also, facing changes in AWS services or deprecations are cases that need some extra effort and modification in all stacks which is theoretically not possible or takes a lot of time.
Configuration
When dealing with IaC, configurable software practices must be adopted to help have a central and easy-to-change stack. There are many ways to use configuration such as configuration files, ParameterStore, or CloudFormation Outputs. The choice of configuration depends on the nature of values, lifecycle, and dependencies.
Configuration File
A configuration file can be ideal for some internal and local configuration elements, the following snippet shows an example of a configuration file in Typescript, including some naming and static config values.
import { ContextVariables, EnvVariable } from "@type";
export type Config = {
contextVariables: ContextVariables;
org: { accounts: { accountName: string;}[];};
}
const defaultConfig: Config = {
contextVariables: {
context: `my-application`,
stage: 'dev',
owner: 'operations',
usage: 'EPHEMERAL',
}
}
const getFinalConfig = (config: Partial<Config>): Config => {
return {
...defaultConfig,
contextVariables: {
...defaultConfig.contextVariables,
...config.contextVariables,
},
...config
}
}
export const getConfig = (stage: EnvVariable): Config => {
switch (stage) {
case 'test':
return getFinalConfig({ contextVariables: {
...defaultConfig.contextVariables,
stage: 'test', usage: 'PRODUCTION' }
});
case 'prod':
return getFinalConfig({ contextVariables: {
...defaultConfig.contextVariables,
stage: 'prod', usage: 'PRODUCTION'
}});
case 'dev':
return getFinalConfig({ contextVariables: {
...defaultConfig.contextVariables,
stage: 'dev', usage: 'EPHEMERAL'
}});
case 'sandbox':
return getFinalConfig({ contextVariables: {
...defaultConfig.contextVariables,
stage: 'sandbox', usage: 'POC'
}});
default:
return getFinalConfig({});
}
};
The config can be later easily fetched in CDK app entry as below
const app = new cdk.App();
const environment = getEnv(app);
const config = getConfig(environment);
Parameter Store
Parameter Store is advantageous when using external configurations such as Application Load Balancer Listener Arn, VPC Id, etc. It is a good practice to use parameters for central configurations or unpredictable cross-stack ones.
Parameter Store as a solution can lead to complexities if:
Lack Of Conventional Naming for parameters.
Consumer stacks spread param fetch in different places.
Excessive use of Parameters
In the following snippet, the parameters fetch is done in the parent stack as passed to the nested stacks, either all nested stacks dont need all parameters.
export class AwsGatewayUsingLoadbalancerStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const albSecurityGroupId = StringParameter.fromStringParameterName(this, 'https-listener-securitygroupe-id', '/alb/securityGroupId').stringValue;
const albHttpsListenerArn = StringParameter.fromStringParameterName(this, 'https-listener-arn', '/alb/httpsListenerArn').stringValue;
const securityGroup = SecurityGroup.fromSecurityGroupId(this, 'alb-security-group', albSecurityGroupId);
const albListener = ApplicationListener.fromApplicationListenerAttributes(this, 'alb-listener', {
listenerArn: albHttpsListenerArn,
securityGroup: securityGroup,
});
const lambda = new NodejsFunction(this, 'example-lambda', {
entry: join(process.cwd(), '/src/example.ts'),
handler: 'handler',
...LambdaConfiguration
});
const target = new ApplicationTargetGroup(this, 'example-target-group', { targets: [ new LambdaTarget(lambda) ] });
albListener.addTargetGroups('dosomething-target', {
priority: 1,
conditions: [
ListenerCondition.hostHeaders(['myservice.example.com']),
ListenerCondition.pathPatterns(['/v1/dosomthing']),
ListenerCondition.httpRequestMethods([
HttpMethod.POST,
HttpMethod.OPTIONS,
]),
],
targetGroups: [target],
});
}
}
The example fetches the parameters early and passes through when you need it. The advantage of this approach is that the parameters are side by side and in a single place, simplifying future changes and evolutions. another approach is the parameter is fetched once and shared, this means if you need multiple times the same parameter, the Api call to parameter store is done in a single place and only once.
Validation
As part of the Infrastructure as code, companies often apply conventions that help simplify governance, such as Available Stages ( dev, test, staging, prod, sandbox, etc. ) or Tagging ( stage, context, project, application, or domain).
Discovering noncompliant resources is one way of doing so, and this is a correct way of analyzing and finding noncompliant resources, but often, these discovered problems take a long time to fix and cause a lot of frustration.
A better approach is to think as an enabler, making the present and future life easy and trying to simplify the way teams must apply the regulations and conventional aspects.
Using CDK, a validator will be executed soon during Synthesizer execution. The following example validates the allowed stage configurations
export class WorkloadEnvValidator implements IValidation {
constructor(private readonly variables: ContextVariables) {}
public validate(): string[] {
const errors: string[] = [];
if(!(isEnvValid(this.variables.stage))) {
errors.push(`Provided Stage value is not a valid environment. Must be one of: ${JSON.stringify(AvailableEnvs)}.`);
}
if(
!['dev', 'sandbox'].includes(this.variables.stage) &&
this.variables.usage !== 'PRODUCTION'
){
errors.push(`Provided Stage value is not eligible to run ephemeral or experimental stacks.`);
}
return errors;
}
}
// IsEnvValid allows Dev, Test, Prod and sandbox
//export const isEnvValid = (env: EnvVariable): env is EnvVariable =>
// IsDevelopmentEnv(env) ||
// IsTestingEnv(env) ||
// IsProductionEnv(env) ||
// IsSandboxEnv(env);
Running the synth using an unknown stage name will result in the following messages.
Aspects
We can use aspects to apply conventions and compliance-related tasks in an automated way, such as Tagging resources, Conventional Naming, etc. The following example shows a simple way of applying parameter naming.
export class ApplyParameterStoreNamingPolicyAspect implements IAspect {
constructor(private readonly variables: ContextVariables) { }
public visit(node: IConstruct): void {
if (node instanceof CfnParameter) {
const inspector = new TreeInspector();
node.inspect(inspector);
const name = inspector.attributes['aws:cdk:cloudformation:props']['name'].toString();
if(name.startsWith(`/${this.variables.stage}/${this.variables.context}/`)) return;
const cleanedName = name
.replace(`${this.variables.stage}`, '')
.replace(`${this.variables.context}`, '')
.replace('//', '');
node.addPropertyOverride('Name', `/${this.variables.stage}/${this.variables.context}${name}` );
Annotations.of(node).addWarningV2(`${name}`, `Parameter Name should start with /${this.variables.stage}/${this.variables.context}, A managed fix is applied by renaming the parameter name but this can have consequences per usage, please apply the correct naming convention` );
}
}
This sample Aspect looks at parameter resources, and transforms the parameter name, and shows a warning in the terminal.
Centralization
There are cases where providing a framework or extensions can be useful, letting the teams use them when necessary, but applying compliance brings a lot of small pieces that can become error-prone to ask teams to apply explicitly or per need.
The best way of achieving the goal but also keeping the rate of change and effort as minimal as possible will be giving abstractions that help teams to achieve the goal with simplicity, this can be achieved with constructs but again creating constructs and applying them everywhere and in all services is not the best choice.
However, providing an abstraction of type Stack can be simple enough to propagate at the enterprise level. The example below shows how can be achieved.
import { ArnFormat, Aspects, Duration, NestedStack, NestedStackProps, Stack, StackProps, Tag } from "aws-cdk-lib";
import { Construct } from "constructs";
import { ApplyDestroyPolicyAspect, ApplyParameterStoreNamingPolicyAspect, ApplyTagsAspect } from "./aspects";
import { ContextVariablesValidator, WorkloadEnvValidator } from "./validators";
import { ContextVariables } from "../types/Context";
import { Rule, Schedule } from "aws-cdk-lib/aws-events";
import { LambdaFunction } from "aws-cdk-lib/aws-events-targets";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { Runtime } from "aws-cdk-lib/aws-lambda";
import { join } from "path";
import { PolicyDocument, PolicyStatement, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam";
export type EnforcedStackProps = StackProps & NestedStackProps & {
contextVariables: ContextVariables;
}
export class EnforcedNestedStack extends NestedStack {
protected readonly REGION: string;
protected readonly ACCOUNT_ID: string;
protected readonly ENV: string;
protected readonly CONTEXT: string;
protected readonly CONTEXT_VARIABLES: ContextVariables;
constructor(scope: Construct, id: string, props: EnforcedStackProps) {
super(scope, id, props);
const { account: ACCOUNT_ID, region: REGION } = Stack.of(this);
this.REGION = REGION;
this.ACCOUNT_ID = ACCOUNT_ID;
const { contextVariables: variables } = props;
this.ENV = variables.stage;
this.CONTEXT = variables.context;
this.CONTEXT_VARIABLES = variables
}
}
export class EnforcedStack extends Stack {
protected readonly REGION: string;
protected readonly ACCOUNT_ID: string;
protected readonly ENV: string;
protected readonly CONTEXT: string;
protected readonly CONTEXT_VARIABLES: ContextVariables;
constructor(scope: Construct, id: string, props: EnforcedStackProps) {
super(scope, id, props);
const { account, region } = Stack.of(this);
this.REGION = REGION;
this.ACCOUNT_ID = ACCOUNT_ID;
const { contextVariables } = props;
this.ENV = contextVariables.stage;
this.CONTEXT = contextVariables.context;
this.CONTEXT_VARIABLES = contextVariables
this.node.addValidation(new ContextVariablesValidator(contextVariables));
this.node.addValidation(new WorkloadEnvValidator(contextVariables));
Aspects.of(this).add(new AwsSolutionsChecks());
if( contextVariables.usage !== 'PRODUCTION' )
Aspects.of(this).add(new ApplyDestroyPolicyAspect());
if( contextVariables.usage === 'EPHEMERAL' ) {
const now = new Date(new Date().getTime() + 24 * 60 * 60 * 1000);
const deleteFunction = new NodejsFunction(this, 'DeleteFunction', {
handler: 'index.handler',
runtime: Runtime.NODEJS_20_X,
timeout: Duration.minutes(15),
entry: join(process.cwd(), 'core/custom-resources', 'remove-stack.ts'),
environment: {
STACK_NAME: this.stackName,
},
role: new Role(this, 'DeleteFunctionRole', {
assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
inlinePolicies: {
'CloudFormationPolicy': new PolicyDocument({
statements: [
new PolicyStatement({
actions: ['cloudformation:DeleteStack'],
resources: [
Stack.of(this).formatArn({
service: 'cloudformation',
resource: 'stack',
resourceName: `${this.stackName}/*`,
arnFormat: ArnFormat.SLASH_RESOURCE_NAME
}),
]
})
]
})
}
})
});
new Rule(this, 'EphemeralRule', {
schedule: Schedule.cron({
minute: now.getMinutes().toString(),
hour: now.getHours().toString(),
day: now.getDate().toString(),
month: now.getMonth().toString(),
year: now.getFullYear().toString(),
}),
targets: [ new LambdaFunction(deleteFunction)],
});
}
Aspects.of(this).add(new ApplyTagsAspect({
context: contextVariables.context,
stage: contextVariables.stage,
owner: contextVariables.owner,
usage: contextVariables.usage,
}));
Aspects.of(this).add(new ApplyParameterStoreNamingPolicyAspect(contextVariables));
}
}
The example applies all aspects and validations in a central place, also, as a specific detail, it uses one-time event bridge schedules to remove the ephemeral stacks after 24 hours.
Conclusion
Adopting IaC was a great evolution in how companies have been dealing with infrastructure, but as the rapidity of cloud infrastructure creation leads to a lot more amount of resources, this becomes important to enable teams to apply practices and help enterprises without becoming road-blockers or reducing the velocity of development teams.
AWS CDK is not only an object-oriented way of writing IaC but also provides many design patterns under the hood that can be extended to some requirements such as compliance, security, etc.
Posted on November 5, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.