Making developers own their infrastructure with Cloud Development kit for Terraform - Thomas Dahll
Originally posted at Distribution Innovation on Medium
This article describes Distribution Innovation’s (DI) journey transitioning from Terraform with Terragrunt to using Cloud Development Kit for Terraform (CDKTF). It explores the motivations behind adopting CDKTF to empower developers with infrastructure ownership, addressing previous challenges such as complex dependencies, lack of resource ownership, and infrastructure management bottlenecks.
The article details the evaluation and implementation process, including practical examples of how DI integrated CDKTF into their workflows using GitHub Actions for CI/CD. Furthermore, it openly discusses encountered challenges, lessons learned, and provides actionable recommendations for teams considering a similar infrastructure shift.
Photo by Erik van Dijk on Unsplash
I work as a tech lead on the DI’s platform team, where we have undergone a significant cloud migration, moving from an on-premise setup to AWS. We’ve fully embraced the cloud, with AWS as our provider, exploring almost every tool AWS has to offer.
We have managed our infrastructure with Terraform and Terragrunt initially with a pop and drop strategy, mirroring our previous on-prem infrastructure. After the inital set of on prem migration, we have in a greater part adopted much of what AWS offers. EKS, STREAMS, EVENTS….
We’re a midsized company with around 35 developers, where innovation is encouraged, and developers are given the freedom to experiment with different AWS services to find the best tool for the job. But with this freedom came a big challenge: how to enable new tech and govern it at the same time.
The tools described for those not in the know.
- Terraform is a framework for writing the configuration for how cloud infrastructure is setup. When applying terraform, terraform executes api calls towards the provider i.e AWS, Helm, Azure, GCP - https://developer.hashicorp.com/terraform
- Terragrunt is meant as a tool to help setting up reusable modules, and applying these to multi-environments - https://terragrunt.gruntwork.io/
- CDKTF - Cloud development kit for Terraform. Tool for writing terraform with familiar programming languages, in this case typescript - https://developer.hashicorp.com/terraform/cdktf
- Github Actions - Github’s tooling for executing CICD workflows - https://docs.github.com/en/actions
Challenges:
We’ve implemented a strict separation of concerns, where every team or domain has its own dev, stage, and prod accounts. In total, we have around 20 AWS accounts for a relatively small company.
- How do we create and maintain infrastructure across all these accounts?
- Organizationally, we don’t have centralized management over our AWS accounts. Therfore no access to AWS Organizations or OUs.
- How do we stay up to date with security requirements?
- How do we enforce some level of standardization?
- Most of our developers are strong in Java but have limited cloud experience. How do we empower developers to manage their own infrastructure?
***
### Bootlenecked infrastructure development stifling cloud adoption
Initially, we managed our infrastructure using Terragrunt, heavily relying on many self managed terraform modules maintained by 1–2 DevOpsers. The strategy was to standardize Terraform modules for commonly used services like:
- Aurora PostgreSQL
- DynamoDB
- Streams (Kinesis, SQS)
- Events
- SNS
…and many more
Whenever a team needed a service we hadn’t yet supported, we had to create a new module for it. These modules often reused shared services, so we ended up creating „modules of modules.“
For the developers, this setup made provisioning new services fairly easy, unless they want non-generic configurations. They just had to add a few fields to a JSON/HCL file, and Terragrunt would handle the rest. Resources were created automatically and placed where they needed to go.
But the initial ease of use came with a cost.
Modules of Modules of Modules… Dependency Trees are a Nightmare
What started as an easy setup quickly became a maintenance nightmare. Making even small changes without breaking existing modules, took forever. Since modules relied on each other, we had a tangled web of dependencies.
We ended up with hundreds of Terraform module files, many of which were tightly coupled.
Maintaining these Terraform modules wasn’t straightforward, and setting up proper testing was difficult for those who weren’t full-time Terraform developers. The setup was brittle, and the code-debt so high, that development of the cloud platform went to a crawl.
Teams had a lack of ownership over their own resources in the cloud
Another issue was that developers had little understanding of how Terraform and Terragrunt worked or how their magical variables translated into actual AWS resources.
They didn’t really „own“ their infrastructure. Everything was a black box to them, and this lack of ownership caused several problems:
- Resources weren’t fine-tuned to fit specific needs.
- Developers didn’t learn much about the cloud; things „just existed.“
- Architecture was not maintained/optimized
- Nothing got deleted, creating resource sprawl.
In practice, developers mostly copied and pasted code, without thinking much about it. They didn’t update module versions often, partly because all of the same resource, shared the same state file. So, if a developer wanted to create a new Kinesis stream, their change could impact every other Kinesis resource in the same account.
Here’s an example of what this terragrunt setup looked like. Simple in setup, but in reality a black boxed mess, where changing the resources was a lot of work, since the modules „never“ were as generic as one hoped.
# services/dev/eu-north-1/streams/kinesis/terragrunt.hcl
include {
path = find_in_parent_folders()
}
## Our terraform module repo
terraform {
source = "git::ssh://git@bitbucket.org/**/**-infra-terraform.git//compositions/stream/kinesis?ref=${local.module_tag}"
}
locals {
account_vars = read_terragrunt_config(find_in_parent_folders("account.hcl"))
region_vars = read_terragrunt_config(find_in_parent_folders("region.hcl"))
aws_account = local.account_vars.locals.aws_account
aws_region = local.region_vars.locals.aws_region
module_tag = "6.4.4"
}
inputs = {
common_tags = {
"Application" = "****"
….
"asset-type" = "kinesis"
}
create_super_user=true
## all resource inputs were merged into one state
streams = merge(
read_terragrunt_config("configurations/****.hcl").inputs,,
read_terragrunt_config("configurations/*****-cache-stream.hcl").inputs,
……
)
remote_state_bucket = "di-terraform-shared-state-${local.aws_account}"
}
# services/dev/eu-north-1/streams/kinesis/configurations/**-cache-stream.hcl
inputs = {
di-**-stream = {
common_tags = {
Application = "***"
jira-component = "**"
}
shard_count = 1
retention = 24
access = {
read = "data"
}
trust = []
}
}
This setup prevented developers from fine-tuning resources or upgrading versions for resources. Also since they shared a state file, their changes could have unintended consequences for other resources.
The Developers disliked working with Terragrunt
While some DevOps folks might enjoy classic Terraform setups or even Terragrunt, the developers on our teams hated it. They actively avoided touching the infrastructure, which meant they weren’t maintaining their own resources, and had little to no ownership of them.
A question of governance
At DI, we’ve prioritised giving developers a lot of freedom in how they use the cloud. This has allowed us to adopt new cloud tools quickly and find the best solutions for specific problems. It’s also motivated developers to find new tools and own their solutions. In this loose-governance environment, ownership comes naturally - if you build something, you own it.
Stricter governance offers more control and reduces the chance for misconfigurations. The configurations are tried and tested, making integration smoother. However, stricter governance requires more oversight, which places a heavier burden on the DevOps team.
In my view, this approach was unsustainable, at least for our companies‘ approach to innovate with new tech. Expecting DevOps to handle everything means they’ll have to make judgment calls on requirements they don’t fully understand, or are non-existing.
Setting new goals for infrastructure development
With the challenges previously discussed in mind, we realized that our approach to infrastructure had to change. We needed to encourage ownership of the architecture within each team and create a system that made developers want to work with Infrastructure as Code.
We opted not to clean up our terragrunt platform. Instead we scrapped it, and we went the tricky round with a strategy of reimporting everything into a new platform.
Bundling our AWS resources together by aws-service-type and not within domains/projects did not work. (Nobody understood where all their project’s resources were placed, and how they related to other projects.) We opted for placing all relevant resources in projects, which were defined by the teams. i.e : app-frontend, app-backend.
Keeping with the goal of promoting developers, we opted for writing infrastructure with a programming language, using patterns developers are familiar with. We evaluated three such options. Pulumi, CDKTF (Cloud development kit for terraform), CDK(Cloudformation - cloud development kit).
We early cut away CDK as an option, since Cloudformation stacks cant „import“ existing infrastructure, which we needed, since and us not daring to recreate IAC unmanaged stacks.
Initially we opted for Pulumi, which is a developer centric framework for IAC using programming languages. We found a lot of good in Pulumi, but we sadly found it a bit unstable, and there was a lack of documentations and forums. So in a quest to make everything „googleable“ we decided on CDKTF. Though CDKTF is not exactly production ready, at the end of the day, it creates Terraform resources which we have a solid understanding of.
Implementation of CDKTF
We chose to implement our infrastructure using TypeScript after evaluating both Pulumi and CDK, finding TypeScript particularly suitable and effective for Infrastructure-as-Code (IaC).
Together with the teams, we organized their infrastructure resources into individual projects, assigning clear ownership to the respective teams responsible for ongoing maintenance. Each project typically represents a collection of related resources required to support a specific domain service or application.
For example, a business-backend project might consist of:
- An Aurora PostgreSQL database
- One or more Amazon SQS queues
- An IAM role assigned to an EKS container, which provides permissions to access SQS queues or retrieve secrets stored securely in AWS Systems Manager Parameter Store.
We own many projects in the platform team, including baselining projects for all the accounts. Baseline projects would be VPCs, WAF, DNS etc.
Our CDKTF platform makes it especially easy for us in the platform team to deploy all of these baselines, with configurations across all our 20 accounts, with just a press of a button.
We in the platform team try to avoid supporting our own modules, as we have had experience with them being unmaintainable. We reckon that smarter people than us have created modules where those are necessary, i.e Cloudfront https://registry.terraform.io/modules/terraform-aws-modules/cloudfront/aws/latest or Aurora https://registry.terraform.io/modules/terraform-aws-modules/rds-aurora/aws/latest.
The new IAC platform makes the developer team create their own IAC project code. Treating it as apps which are to be consistent between environment with only minor configuration differences.
Example of a project folder structure
~/git/com.github/distribution-innovation/di-domain-iac-app-backend
❯ git ls-tree -r - name-only HEAD | tree - fromfile
.
├── .gitignore
├── README.md
├── __tests__
│ └── main-test.ts
├── cdktf.json
├── help
├── jest.config.js
├── main.ts
├── package-lock.json
├── package.json
├── queues.txt
├── services
│ ├── apigateway.ts
│ ├── auroraRds.ts
│ ├── index.ts
│ ├── kinesisStreams.ts
│ ├── s3.ts
│ └── sqs.ts
├── setup.js
└── tsconfig.json
The project app-backends cdktf entrypoint.
// di-domain-iac/app-backend/main.ts
import * as constants from "../constants";
import { Construct } from "constructs";
import { App, TerraformStack } from "cdktf";
import * as services from "./services";
//DI is library with a collection of commonly used constructs. Mainly setting up state, roles and providers + role logic
import * as DI from "@di/di-cdktf-lib";
import { POLICIES } from "../constants";
const tags: DI.Tags = {
Application: "app-backend",
git: "https://github.com/**/di-domain-iac/app-backend",
"billed-team": "domain",
"service-team": "domain"
}
//TerraformConfig is a config setup for working with the contex/provide setup
interface appBackendConfig extends DI.TerraformConfig {
federatedOidcArn: string;
securityGroupId: string;
vpcId: string;
}
interface appApiConfig extends DI.TerraformConfig {
topDomain: string;
}
const devConfig: appBackendConfig = {
…DI.createConfig(
DI.CONSTANTS.ENV_DEV,
DI.CONSTANTS.TEAM_domain,
tags.Application),
accountNumber: constants.DEV_ACCOUNT,
federatedOidcArn: constants.IRSA_REF_DEV,
securityGroupId: "sg-**", //rds_pg_access
vpcId: "vpc-**"
}
…
const prodConfig: appBackendConfig = {
…
}
const devApiConfig: appApiConfig = {
…
}
…
class app extends TerraformStack {
constructor(scope: Construct, id: string, envConfig: appBackendConfig) {
super(scope, id);
// Calling our internal helper library for setting
DI.setupProvidersAndStateBackend(this, envConfig, tags);
createEksServiceRole(this, envConfig);
// The collections of services/resources for the project
services.createRdsAurora(this, envConfig, tags);
services.createKinesisStreams(this, envConfig, tags);
services.createS3Buckets(this, envConfig);
services.createSqsQueues(this, envConfig);
}
}
// For a smaller stack size, the apiStack has been made into its own stack
class apiStack extends TerraformStack {
constructor(scope: Construct, id: string, envConfig: appApiConfig) {
super(scope, id);
DI.setupProvidersAndStateBackend(this, envConfig, tags);
services.createApiGateway(this, envConfig, tags);
}
}
// The project is deployed into multiple environments. <stackname>_<team>_<env>
// (<team>_<env> is in fact a marker for one env)
const app = new App();
new app(app, "app_domain_dev", devConfig);
new apiStack(app, "appApi_domain_dev", devApiConfig);
// ...
app.synth();
function createEksServiceRole(scope: Construct, envConfig: DI.TerraformConfig) {
const managedPolicyArns = [
POLICIES.DYNAMODB,
POLICIES.KINESIS,
POLICIES.S3,
POLICIES.SQS,
POLICIES.CLOUDWATCH,
POLICIES.EVENTBRIDGE,
POLICIES.REKOGNITION
];
const iamRoleConfig: DI.IamRoleConfig = {
…envConfig,
managedPolicyArns: managedPolicyArns,
serviceAccounts: ["di-app:di-app"],
name: "di-app-service",
};
// Helper for creating IAM roles. defined for EKS
DI.createIamRole(scope, iamRoleConfig);
}
Examle of simple resources created with cdktf.
//app-backend/services/kinesisStreams.ts
// Using the actual typescript variant of the terraform resource
import { KinesisStream } from "@cdktf/provider-aws/lib/kinesis-stream";
import { EnvConfig, Tags } from '@di/di-cdktf-lib';
import { Construct } from "constructs";
export function createKinesisStreams(stack: Construct, _envConfig: EnvConfig, tags: Tags) {
new KinesisStream(stack, "app-backend-cache-stream", {
name: "di-app-cache-stream",
retentionPeriod: 24,
shardCount: 1,
shardLevelMetrics: ["IncomingBytes", "OutgoingBytes"],
streamModeDetails: {
streamMode: "PROVISIONED",
},
});
new KinesisStream(stack, "app-backend-load-carrier-relation-stream", {
name: "di-app-load-carrier-relation-stream",
retentionPeriod: 24,
shardCount: 1,
shardLevelMetrics: ["IncomingBytes", "OutgoingBytes"],
streamModeDetails: {
streamMode: "PROVISIONED",
},
});
}
Helping the teams maintain their CDKTF projects
The more complex parts of infrastructure-as-code - such as provider configuration, state management, and state locking - are abstracted into a shared cdktf-lib, which also includes common constants used by developers.
This library includes a registry-based validation that ensures the correct IAM role is assumed when running Terraform locally, verifying that the state file matches the expected AWS account role.
We deliberately avoid enforcing strict CI/CD-only rules for IaC changes. Since each team owns their infrastructure, we treat developers as responsible actors capable of deciding when to apply changes locally and when to rely on automation. However, we strongly emphasize the importance of keeping the main branch aligned with the actual state of infrastructure.
As a guardrail, we run weekly diff checks in CI/CD pipelines across all IaC repositories to ensure that the main branches are in sync with the deployed infrastructure. Which we post notifications for in their respective slack channels.
Slack updates every week, analysing drift across the domains
We have also generated a set of cli tools to help developers navigate AWS. Connecting to correct account and using the correct AWS_PROFILE needed for their projects.
❯ di assumeRole
Set role for AWS
1: domainA
2: domainB
3: domainC
4: domainD
Select account (0 to quit): 1
1: dev
2: stg
3: prod
4: backup
Select environment (0 to quit): 1
1: FullAccess
Select role (0 to quit): 1
Executing command:/opt/homebrew/bin/aws eks update-kubeconfig - name domainA-eun1-dev-1 - region eu-north-1 - profile domainA-dev-FullAccess
Updated context arn:aws:eks:eu-north-1:**:cluster/domainA-eun1-dev-1 in /Users/***/.kube/config
Export statement copied to clipboard.
Paste to shell.
export AWS_PROFILE=domainA-dev-FullAccess
#cdktf plan/apply for stacks in a cdktf project in the foundations domain
cdktf plan hostedZone_domain_dev
CICD
We use github actions as our weapon of choice for the CICD. We use reusable workflows patterns. Where workflows can use other predefined workflows as part of its steps
Here is a quick rundown of the executing workflows in github actions.
Reusable workflow for plan and apply
DevO.github/workflows/reusable_cdktf_plan_apply.yml
name: Execute CDKTF plan/apply on changed directories
on:
workflow_call:
inputs:
role-arn:
required: true
type: string
description: "The AWS role to assume."
LATEST_APPLIED_DEV_COMMIT:
required: true
type: string
description: "The last commit that was applied in dev."
LATEST_APPLIED_STG_COMMIT:
required: true
type: string
description: "The last commit that was applied in stg."
LATEST_APPLIED_PROD_COMMIT:
required: true
type: string
description: "The last commit that was applied in prod."
permissions:
contents: write
pull-requests: write
id-token: write
actions: read
jobs:
detect-changes:
name: Detect changes
runs-on: [self-hosted, developer-foundations, ubuntu-latest-no-docker]
outputs:
working_directory_dev: ${{ steps.detect-changes-dev.outputs.changed-dirs }}
ignored_dirs_dev: ${{ steps.detect-changes-dev.outputs.ignored-dirs }}
…
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# Use the existing cdktf-action-changed-dirs action
- name: Compare with latest applied commit and set outputs
id: detect-changes-dev
uses: Distribution-Innovation/actions/cdktf_changed_dirs@v1.6.2 # Path to your custom action (or you can publish it and reference via repository)
with:
use-apply-ignore: true
LATEST_APPLIED_COMMIT: ${{ inputs.LATEST_APPLIED_DEV_COMMIT }}
Commit_key: "LATEST_APPLIED_DEV_COMMIT"
- name: exit if no changes
run: |
if [ -z "${{ steps.detect-changes-dev.outputs.changed-dirs }}" || -z "${{ steps.detect-changes-stg.outputs.changed-dirs }}" || -z "${{ steps.detect-changes-prod.outputs.changed-dirs }}" ]; then
echo "No changes detected. Exiting…"
exit 0
fi
echo "changed-dirs-dev=${{ steps.detect-changes-dev.outputs.changed-dirs }}" >> "$GITHUB_OUTPUT"
echo "ignored-dirs-dev=${{ steps.detect-changes-dev.outputs.ignored-dirs }}" >> "$GITHUB_OUTPUT"
…
cdktf-review-dev:
name: review-dev
if: ${{ needs.detect-changes.outputs.working_directory_dev != '' }}
uses: distribution-innovation/actions/.github/workflows/reusable_execute_cdktf.yml@v1.6.0
needs: detect-changes
with:
action: "plan"
environment: dev
working-directories: ${{ needs.detect-changes.outputs.working_directory_dev }}
ignore-directories: ${{ needs.detect-changes.outputs.ignored_dirs_dev }}
role-arn: ${{ inputs.role-arn }}
secrets: inherit
cdktf-apply-dev:
name: apply-dev
needs: [detect-changes, cdktf-review-dev]
uses: distribution-innovation/actions/.github/workflows/reusable_execute_cdktf.yml@v1.6.0
with:
action: "apply"
environment: dev
working-directories: ${{ needs.detect-changes.outputs.working_directory_dev }}
ignore-directories: ${{ needs.detect-changes.outputs.ignored_dirs_dev }}
role-arn: ${{ inputs.role-arn }}
secrets: inherit
post-apply-dev:
name: post-apply dev
needs: [cdktf-apply-dev]
runs-on: [self-hosted, developer-foundations, ubuntu-latest-no-docker]
steps:
- name: Compare with latest applied commit and set outputs
id: set-new-applied-commit
run: |
curl -X PATCH
https://api.github.com/repos/${{ github.repository }}/actions/variables/LATEST_APPLIED_DEV_COMMIT
-H "Accept: application/vnd.github+json"
-H "Authorization: Bearer ${{ secrets.PAT_GITHUB_ENV_TOKEN }}"
-d '{"name":"LATEST_APPLIED_DEV_COMMIT","value":"${{ github.sha }}"}'
Implementing the workflow in IAC domain repo
name: Execute CDKTF job
on:
workflow_call:
inputs:
environment:
required: true
type: string
description: "The environment to deploy to."
working-directories:
required: true
type: string
description: "The directory to run the CDKTF commands in."
ignore-directories:
type: string
description: "The directories to ignore."
role-arn:
required: true
type: string
description: "The AWS role to assume."
action:
required: true
type: string
default: "plan"
permissions:
id-token: write
contents: write
jobs:
Execute-CDKTF:
runs-on: [self-hosted, developer-foundations, ubuntu-latest-no-docker]
environment: ${{ inputs.action != 'plan' && inputs.environment || '' }}
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: configure aws credentials
uses: aws-actions/configure-aws-credentials@v4.0.2
with:
role-to-assume: ${{ inputs.role-arn }}
role-session-name: GitHub_to_AWS_via_FederatedOIDC
aws-region: "eu-north-1"
- name: Execute cdktf review
id: run_cdktf_action
uses: distribution-innovation/actions/cdktf_action@v1.6.0
with:
environment: ${{ inputs.environment }}
dirs-to-process: ${{ inputs.working-directories }}
ignore-directories: ${{ inputs.ignore-directories }}
role-arn: ${{ inputs.role-arn }}
terraform-version: ${{ vars.TERRAFORM_VERSION }}
action: ${{ inputs.action }}
Wisdoms gained during the CDKTF project
Moving our infrastructure platform unto CDKTF and moving the responsibility unto the developers have given us many gains and some challenges.
All in all the experience is a positive one. The developers mostly like the freedom of being able to create what they want when they want, and at the same time they have cloud experts in close proximity when needed.
Education is important, making sure that all the developers are familiar with the IAC platform, and making sure this is not a hands off part of their job.
Lets start with the gains
-
The developers have started to maintain their own resources, optimising them for their specific use cases.
-
Less DevOps bottlenecks. The developers can fix most of their own infrastructure.
-
The developers, many not very cloud proficient, have gotten a better understanding of how to work towards AWS as a cloud provider.
-
Devopser dont need to create x amount of terraform modules that they need to maintain.
-
Greater understanding of how IAC works, and the importance of keeping it synced with the actual reality.
-
The devoplers are also able to better solve complex tasks programatically with typescript. Compared with the statically way of using terraform, which has some limitations on dynamic logic, from a developlers point of view.
-
The platform team, with native devopsers, actually prefer CDKTF instead of Terraform, also for very devops sentric IAC tasks, like VPCs and EKS(K8S)
Challenges encountered
-
Many tools, and updates have been implemented throughout the project, and communicating and keeping the developers up to date with and making sure their local setup is configured correctly these have been challenging. Though we have implemented CLI tools to keep their local systems up to date with some of the requirements.
-
We are java and not Node proficient in the company so Node specifics some times get challenging.
-
The developers get more work, and need to cover yet another technical area, which can be a bit of an overload.
-
Sometimes you have to do „native“ terraform logic in cdktf, meaning you need to do logical operations on unknown types (meaning resource in terraform with unknown values before they are created ). This can be quite difficult and requires terraform adepts to do. Usually only occurs in baseline platform logic though.
We have done our best at mitigating these challenges, and we experience that education is the best tool for it and at the same time trying to avoid infodumps. So that the developers looks at the project a as a positive experience.
Nice to think about before starting a migration to cdktf
Have good plans for state names (the id of the resources), make sure you avoid duplicates for these when doing terraform programatically.
Clear plan for how state files should be managed.
Ownership is most important; clearly define what platform-team define and what the team themselves define.
Use typescript types for what it’s worth when creating library features, have a think about how you would change it later. Breaking changes in own libraries can be a lot of work, thus Types and Polymorphism are your friend.
The actual cloud infrastructure is the truth, not terraform. When requiring external data objects, like finding VPC ids, use data objects towards AWS not terraform outputs.
import { Construct } from "constructs";
import { DataTerraformRemoteStateS3, S3Backend } from "cdktf";
import { AwsProvider } from "@cdktf/provider-aws/lib/provider";
import { Tags, TerraformConfig, validateTerraformConfig } from "./config";
import { ExternalProvider } from "@cdktf/provider-external/lib/provider"
import { DataExternal } from "@cdktf/provider-external/lib/data-external";
/**
* Sets up the AWS provider and S3 backend for a given Terraform stack.
*
* @author Thomas Dahll - DI Platform
*
* This function initializes an AWS provider and configures an S3 backend for storing the Terraform state.
* It takes the Terraform stack, environment configuration, and tags as inputs.
*
* @param {Construct} stack - CDKTF stack to which the AWS provider and S3 backend will be added.
* @param {TerraformConfig} terraformConfig - The environment configuration object containing AWS role, state bucket, etc.
* @param {Tags} tags - The tags object for tagging AWS resources.
* @param {boolean} alignWithLegacy - Optional. If true, the state will not set values not compatible with legacy state.
* @returns {ProviderStateConfig} - The AWS provider and external provider objects.
*
* @example
* const terraformConfig: EnvTerraformConfigConfig = {
* env: "dev",
* stateBucket: "terraform-state-dev-******",
* roleToAssume: "arn:aws:iam::******:role/DiPlatform",
* stateKey: "foundations/keycloak/dev.tfstate",
* region: "eu-north-1",
* };
*
* const tags: Tags = {
* Application: "App",
* realm: "dev",
* // ... other tags ...
* };
*
* setupProvidersAndStateBackend(myStack, terraformConfig, tags);
*
*
*
*/
export interface ProviderStateConfig {
awsProvider: AwsProvider;
externalProvider: ExternalProvider;
s3Backend: S3Backend;
}
export function setupProvidersAndStateBackend(scope: Construct, terraformConfig: TerraformConfig, tags: Tags, alignWithLegacy: boolean = false): ProviderStateConfig {
validateTerraformConfig(terraformConfig);
const externalProvider = new ExternalProvider(scope, "external", {});
const awsProvider = createProvider(scope, "AWS", terraformConfig, tags, alignWithLegacy, true);
const s3Backend = setUpStateBackend(scope, terraformConfig)
return {
awsProvider,
externalProvider,
s3Backend,
};
}
export function createProvider(scope: Construct, id: string, terraformConfig: TerraformConfig, tags: Tags, alignWithLegacy?: boolean, defaultProvider: boolean = false) {
const configGeneratedTags: { [key: string]: any } = {};
if (!alignWithLegacy) {
const terraformVersion = new DataExternal(scope, "terraform_version-" + id, {
program: ["bash", "-c", "echo '{"version": "'$(terraform version | grep 'Terraform v' | cut -d ' ' -f 2)'"}'"],
});
configGeneratedTags['terraform_version'] = terraformVersion.result.lookup("version");
}
let config = {
region: terraformConfig.region || "eu-north-1",
defaultTags: [
{
tags: {
...tags,
...configGeneratedTags,
"billed-service": tags.Application,
realm: terraformConfig.env,
},
},
],
assumeRole: [
{
roleArn: terraformConfig.roleToAssume,
sessionName: `terraform_${terraformConfig.env}_deployment`,
},
],
};
if (!defaultProvider) {
return new AwsProvider(scope, id, { ...config, alias: id });
} else {
return new AwsProvider(scope, id, config);
}
}
export function setUpStateBackend(scope: Construct, terraformConfig: TerraformConfig): S3Backend {
const stateBucket = terraformConfig.stateBucket || getDefaultBucket(terraformConfig.env);
return new S3Backend(scope, {
bucket: stateBucket,
key: terraformConfig.stateKey,
region: "eu-north-1",
encrypt: true,
dynamodbTable: `arn:aws:dynamodb:eu-north-1:******:table/TerraformLockTable-${terraformConfig.env}`,
assumeRole: {
roleArn: terraformConfig.roleToAssume,
}
});
}
/**
*
* Used to get remote state from S3 bucket. Etc. fetching VPC id from remote state. Or subnet groups, sec groups etc.
* @param id - The id to be used for the remote state object. -> state_<id>
* @param {Construct} scope - CDKTF stack to which scope the remote state within.
* @param {TerraformConfig} terraformConfig - The environment configuration object containing AWS role, state bucket, etc.
* @returns {DataTerraformRemoteStateS3} - The remote state for the key.
*
*
* @example
* const s3State = getRemoteS3State(scope, terraformConfig, "foundations/baseline/vpc/application/foundations-dev.tfstate");
* let applicationVpcId = s3State.get("vpc_id").toString();
*/
export function getRemoteS3State(scope: Construct, id: string, terraformConfig: TerraformConfig, key: string): DataTerraformRemoteStateS3 {
return new DataTerraformRemoteStateS3(scope, "state_" + id, {
bucket: terraformConfig.stateBucket || getDefaultBucket(terraformConfig.env),
key: key,
region: "eu-north-1",
assumeRole: {
roleArn: terraformConfig.roleToAssume,
},
});
}
function getDefaultBucket(env: string): string {
switch (env) {
case "dev":
return "terraform-state-dev-******";
case "stg":
return "terraform-state-stg-******";";
case "prod":
return "terraform-state-prod-******";";
default:
throw new Error(`Unknown environment: ${env}`);
}
}
export function createUsEastProvider(stack: Construct, id: string, terraformConfig: TerraformConfig, tags: Tags) {
return new AwsProvider(stack, "AWS-us-east-1-" + id, {
region: "us-east-1",
alias: "us-east-1-" + id,
defaultTags: [
{
tags: {
...tags,
realm: terraformConfig.env,
},
},
],
assumeRole: [
{
roleArn: terraformConfig.roleToAssume,
sessionName: `terraform_${terraformConfig.env}_deployment`,
},
],
});
}
import { TerraformConfig } from "./config";
/**
* Validates a given state key string for specific formatting rules.
*
* The function checks two main parts of the `stateKey`:
* 1. The first part of the state key must be one of the predefined domain keys.
* 2. The last part of the state key must follow a specific naming convention
* and end with a '.tfstate' extension.
*
* If the state key does not end with '.tfstate', the function appends this extension.
*
* @param {string} stateKey - The state key string to be validated.
* @returns {string} The validated state key with the '.tfstate' extension.
* @throws Will throw an error if the first part of the state key is not one of the valid domain keys.
* @throws Will throw an error if the last part of the state key does not match the expected naming convention.
*
* @example
* // returns 'foundations/keycloak/dev.tfstate'
* validateStateKey('foundations/keycloak/dev');
*
* @example
* // throws an error
* validateStateKey('invalid/keycloak/dev');
*/
export function validateStateKey(terraformConfig: TerraformConfig): string {
let stateKey = terraformConfig.stateKey;
const validDomainKeys = ["domainA", "domainB", "domainC", "domainD", "domainE"];
const tfstateExtension = ".tfstate";
// Split the stateKey to analyze its parts
const parts = stateKey.split('/');
const firstPart = parts[0];
const lastPart = parts[parts.length - 1].split('.')[0]; // Removing the file extension
// Validate first part
if (!validDomainKeys.includes(firstPart)) {
throw new Error(`Invalid stateKey: "${stateKey}". The first part must be one of ${validDomainKeys.join(", ")}`);
}
// Validate last part
const lastPartRegex = new RegExp(`^(${validDomainKeys.join("|")})-(dev|stg|prod)$`);
if (!lastPartRegex.test(lastPart)) {
throw new Error(`Invalid stateKey: "${stateKey}". The last part must match the pattern name-dev|stg|prod`);
}
validateCorrectRoleForStateKey(terraformConfig.roleToAssume, lastPart);
// Ensure the stateKey ends with .tfstate
if (!stateKey.endsWith(tfstateExtension)) {
stateKey += tfstateExtension;
}
return stateKey;
}```