Bootstrap Terraform on AWS

How-To Bootstrap Terraform on AWS

Bootstrap Terraform on AWS

The Challenge

Terraform is a great product for managing infrastructure on AWS however many people start by creating an IAM user and sharing access keys into configuration files. This is really bad from a security aspect as these often get checked into version control and even worse in a public repo.

We can use the AWS ecosystem for your terraform workflow using CodeCommit, CodePipeline, CodeBuild and IAM. We can give the access we need without managing any access keys or secrets.

We store all our terraform code within CodeCommit which ticks our boxes for versioning and traceability for our infrastructure.

We use Terraform S3 and DynamoDB backend capabilities to store state and provide locking to maintain consistency. Changes pulled from CodeCommit are run through a Terraform Validate and Plan operation prior to making any changes to our infrastructure.

Lastly we run a Terraform Apply to make the requested changes to our infrastructure.

That’s the highlevel summary of what we are going to build. Here is the Cloudformation template to create it. Yes, we are using Cloudformation however this is only to bootstrap our AWS account so that we can use Terraform moving forward and control all resources via our CodeCommit Repo.

---
AWSTemplateFormatVersion: 2010-09-09
Description: 'Create the supporting resources for Terraform Infrastructure
  state and build pipelines'
 
Parameters:
  ServiceName:
    Description: 'Specifies the hosted service name used within component
      naming'
    Type: String
 
  TerraformVersion:
    Description: 'Specifies the Terraform binary version'
    Type: String
    Default: '0.12.10'
 
  LogsRetentionInDays:
    Description: 'Specifies the number of days you want to retain log
      events in the specified log group'
    Type: Number
    Default: 3
    AllowedValues: [1, 3, 5, 7, 14, 30]
 
Resources:
  TerraformBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    Properties:
      Tags:
        - Key: Name
          Value: !Join ['-', ['AP2', 'INF', !Ref 'ServiceName', 'S3', 'Terraform']]
      BucketName: !Sub '${ServiceName}-${AWS::Region}-terraform'
      AccessControl: LogDeliveryWrite
      VersioningConfiguration:
        Status: Enabled
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
 
  TerraformBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref 'TerraformBucket'
      PolicyDocument:
        Id: Content
        Version: '2012-10-17'
        Statement:
          - Action: '*'
            Condition:
              Bool:
                aws:SecureTransport: 'false'
            Effect: Deny
            Principal: '*'
            Resource: !Join ['', ['arn:aws:s3:::', !Ref 'TerraformBucket', '/*']]
            Sid: S3ForceSSL
          - Action: 's3:PutObject'
            Condition:
              'Null':
                s3:x-amz-server-side-encryption: 'true'
            Effect: Deny
            Principal: '*'
            Resource: !Join ['', ['arn:aws:s3:::', !Ref 'TerraformBucket', '/*']]
            Sid: DenyUnEncryptedObjectUploads
 
  TerraformInfraDynamoDBTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: 'terraform-infrastructure-locks'
      BillingMode: PAY_PER_REQUEST
      KeySchema:
        -
          AttributeName: "LockID"
          KeyType: "HASH"
      AttributeDefinitions:
        -
          AttributeName: "LockID"
          AttributeType: "S"
      SSESpecification:
        SSEEnabled: true
 
  TerraformInfrastructureCodeRepository:
    Type: AWS::CodeCommit::Repository
    Properties:
      RepositoryName: !Sub 'app-${ServiceName}-infrastructure'
      RepositoryDescription: !Sub 'Repository for ${ServiceName} Infrastructure IaC'
 
  CodeBuildServiceRole:
    Type: AWS::IAM::Role
    Properties:
      Path: '/managed/'
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/AdministratorAccess'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          -
            Action: 'sts:AssumeRole'
            Effect: Allow
            Principal:
              Service:
                - codebuild.amazonaws.com
 
  TerraformInfrastructureValidatePlanCodeBuild:
    Type: AWS::CodeBuild::Project
    Properties:
      Artifacts:
        Type: NO_ARTIFACTS
      Source:
        Location: !Sub "https://git-codecommit.ap-southeast-2.amazonaws.com/v1/repos/app-${ServiceName}-infrastructure"
        Type: "CODECOMMIT"
        BuildSpec: 'codebuild/validate_plan.yml'
      Environment:
        ComputeType: "BUILD_GENERAL1_SMALL"
        Image: "aws/codebuild/amazonlinux2-x86_64-standard:1.0"
        Type: "LINUX_CONTAINER"
        EnvironmentVariables:
          - Name: TERRAFORM_VERSION
            Value: !Ref 'TerraformVersion'
      Description: Verify Terraform Syntax and run Plan
      ServiceRole: !GetAtt CodeBuildServiceRole.Arn
      TimeoutInMinutes: 300
 
  TerraformInfrastructureValidatePlanLogGroup:
    Type: 'AWS::Logs::LogGroup'
    Properties:
      LogGroupName: !Sub '/aws/codebuild/${TerraformInfrastructureValidatePlanCodeBuild}'
      RetentionInDays: !Ref LogsRetentionInDays
 
  TerraformInfrastructureApplyCodeBuild:
    Type: AWS::CodeBuild::Project
    Properties:
      Artifacts:
        Type: NO_ARTIFACTS
      Source:
        Location: !Sub "https://git-codecommit.ap-southeast-2.amazonaws.com/v1/repos/app-${ServiceName}-infrastructure"
        Type: "CODECOMMIT"
        BuildSpec: 'codebuild/apply.yml'
      Environment:
        ComputeType: "BUILD_GENERAL1_SMALL"
        Image: "aws/codebuild/amazonlinux2-x86_64-standard:1.0"
        Type: "LINUX_CONTAINER"
        EnvironmentVariables:
          - Name: TERRAFORM_VERSION
            Value: !Ref TerraformVersion
      Description: Apply Terraform Plan
      ServiceRole: !GetAtt CodeBuildServiceRole.Arn
      TimeoutInMinutes: 300
 
  TerraformInfrastructureApplyLogGroup:
    Type: 'AWS::Logs::LogGroup'
    Properties:
      LogGroupName: !Sub '/aws/codebuild/${TerraformInfrastructureApplyCodeBuild}'
      RetentionInDays: !Ref LogsRetentionInDays
 
  TerraformInfrastructureDestroyCodeBuild:
    Type: AWS::CodeBuild::Project
    Properties:
      Artifacts:
        Type: NO_ARTIFACTS
      Source:
        Location: !Sub "https://git-codecommit.ap-southeast-2.amazonaws.com/v1/repos/app-${ServiceName}-infrastructure"
        Type: "CODECOMMIT"
        BuildSpec: 'codebuild/destroy.yml'
      Environment:
        ComputeType: "BUILD_GENERAL1_SMALL"
        Image: "aws/codebuild/amazonlinux2-x86_64-standard:1.0"
        Type: "LINUX_CONTAINER"
        EnvironmentVariables:
          - Name: TERRAFORM_VERSION
            Value: !Ref TerraformVersion
      Description: Destroy Terraform State - WILL DESTROY YOUR WHOLE CONFIG!
      ServiceRole: !GetAtt CodeBuildServiceRole.Arn
      TimeoutInMinutes: 300
 
  TerraformInfrastructureDestroyLogGroup:
    Type: 'AWS::Logs::LogGroup'
    Properties:
      LogGroupName: !Sub '/aws/codebuild/${TerraformInfrastructureDestroyCodeBuild}'
      RetentionInDays: !Ref LogsRetentionInDays
 
  PipelineExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      Path: '/managed/'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          -
            Action: 'sts:AssumeRole'
            Effect: Allow
            Principal:
              Service:
                - codepipeline.amazonaws.com
      Policies:
        -
          PolicyName: CodePipelinePassRoleAccess
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              -
                Action: 'iam:PassRole'
                Effect: Allow
                Resource: !GetAtt CodeBuildServiceRole.Arn
        -
          PolicyName: CodePipelineS3ArtifactAccess
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              -
                Action:
                  - 's3:GetObject'
                  - 's3:GetObjectVersion'
                  - 's3:GetBucketVersioning'
                  - 's3:PutObject'
                Effect: Allow
                Resource:
                  - !Sub 'arn:aws:s3:::${TerraformBucket}'
                  - !Sub 'arn:aws:s3:::${TerraformBucket}/*'
        -
          PolicyName: CodePipelineGitRepoAccess
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              -
                Action:
                  - 'codecommit:GetBranch'
                  - 'codecommit:GetCommit'
                  - 'codecommit:UploadArchive'
                  - 'codecommit:GetUploadArchiveStatus'
                  - 'codecommit:CancelUploadArchive'
                Effect: Allow
                Resource:
                  - !GetAtt TerraformInfrastructureCodeRepository.Arn
        -
          PolicyName: CodePipelineBuildAccess
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              -
                Action:
                  - 'codebuild:StartBuild'
                  - 'codebuild:StopBuild'
                  - 'codebuild:BatchGetBuilds'
                Effect: Allow
                Resource:
                  - !GetAtt TerraformInfrastructureValidatePlanCodeBuild.Arn
                  - !GetAtt TerraformInfrastructureApplyCodeBuild.Arn
 
  TerraformInfrastructurePipeline:
    Type: AWS::CodePipeline::Pipeline
    Properties:
      ArtifactStore:
        Location: !Ref TerraformBucket
        Type: S3
      Name: !Sub ${ServiceName}-terraform-infrastructure
      RoleArn: !GetAtt PipelineExecutionRole.Arn
      Stages:
        -
          Name: GetSource
          Actions:
            -
              Name: TerraformSource
              ActionTypeId:
                Category: Source
                Owner: AWS
                Provider: CodeCommit
                Version: '1'
              Configuration:
                RepositoryName: !Sub 'app-${ServiceName}-infrastructure'
                BranchName: master
              OutputArtifacts:
                - Name: SourceArtifact
              RunOrder: 1
        -
          Name: ValidatePlan
          Actions:
            -
              Name: TerraformValidatePlan
              ActionTypeId:
                Category: Build
                Owner: AWS
                Provider: CodeBuild
                Version: '1'
              Configuration:
                ProjectName: !Ref 'TerraformInfrastructureValidatePlanCodeBuild'
              InputArtifacts:
                - Name: SourceArtifact
              RunOrder: 1
        -
          Name: Apply
          Actions:
            -
              Name: TerraformApply
              ActionTypeId:
                Category: Build
                Owner: AWS
                Provider: CodeBuild
                Version: '1'
              Configuration:
                ProjectName: !Ref 'TerraformInfrastructureApplyCodeBuild'
              InputArtifacts:
                - Name: SourceArtifact
              RunOrder: 1
 
Outputs:
  TerraformInfrastructureCodeRepository:
    Description: Terraform Infrastructure Code Repository CloneUrlHttp
    Value: !GetAtt 'TerraformInfrastructureCodeRepository.CloneUrlHttp'
    Export:
      Name: TerraformInfrastructureCodeRepositoryCloneUrlHttp
 
  TerraformBucketName:
    Description: Terraform State Bucket Name
    Value: !Ref 'TerraformBucket'
    Export:
      Name: 'TerraformBucketName'

Cloudformation Resources

ServiceName – Prefix for your CodePipeline and CodeCommit resources

TerraformVersion – Version of Terraform to use

LogsRetentionInDays – CodeBuild logs will be output to CloudWatch. This determines the retention period.

TerraformBucket – S3 bucket used to store Terraform state and CodePipeline artefacts, public access restricted

TerraformBucketPolicy – Policy setting with secure defaults

TerraformInfraDynamoDBTable – DynamoDB table to maintain Terraform locks, SSE enabled

TerraformInfrastructureCodeRepository – CodeCommit repo for all our terraform configuration files

CodeBuildServiceRole – IAM Role for CodeBuild, this has been given the AWS Managed Policy AdministratorAccess however this can be restricted only to be allow to provision resources that are relevant to your Terraform configuration. Least privilege should be at the forefront of your mind here to please adjust as required.

TerraformInfrastructureValidatePlanCodeBuild – CodeBuild project to perform the Terraform Validate & Plan

TerraformInfrastructureValidatePlanLogGroup – CloudWatch log group for the above CodeBuild project

TerraformInfrastructureApplyCodeBuild – CodeBuild project to perform the Terraform Apply

TerraformInfrastructureApplyLogGroup – CloudWatch log group for the above CodeBuild project

TerraformInfrastructureDestroyCodeBuild

TerraformInfrastructureDestroyLogGroup

PipelineExecutionRole – IAM Role for CodePipeline. This requires access to the above CodeBuild projects and access to commit triggers in CodeCommit

TerraformInfrastructurePipeline – Create the pipeline plumbing the above components into a workflow

TerraformInfrastructureCodeRepository – CodeCommit Repo name generated

TerraformBucketName – State Bucket name generated

CodeCommit Structure

Our CodeBuild projects expect the following structure within our CodeCommit repo.

/
/env/terraform_state.tfvars
/env/variables.tfvars
/codebuild
/codebuild/validate_plan.yml
/codebuild/apply.yml
/codebuild.destroy.yml

terraform_state.tfvars – This file contains our references to our AWS components, see the following for more details – https://www.terraform.io/docs/backends/config.html

# /env/terraform_state.tfvars
bucket         = "S3_BUCKET_NAME_FROM_CLOUDFORMATION_OUTPUTS"
key            = "infrastructure/terraform.tfstate"
region         = "ap-southeast-2"
dynamodb_table = "terraform-infrastructure-locks"
encrypt        = true
variables.tfvars – This is where I store variables to inject into the Terraform config. For now this is blank

CodeBuild Buildspec.yml

CodeBuild requires a buildspec file to tell it what actions to take. All our buildspec files live within the codebuild dir in CodeCommit.

validate_plan.yml – The following build spec runs our Terraform Validate & Plan

# /codebuild/validate_plan.yml
version: 0.2
 
env:
  variables:
    TF_IN_AUTOMATION: "true"
 
phases:
  install:
    runtime-versions:
      python: 3.7
    commands:
      - "cd /usr/bin"
      - "curl -O https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip"
      - "unzip terraform_${TERRAFORM_VERSION}_linux_amd64.zip"
  build:
    commands:
      - cd "${CODEBUILD_SRC_DIR}"
      - terraform init -backend-config="envs/terraform_state.tfvars" -no-color
      - terraform validate -no-color
      - terraform plan -var-file="envs/variables.tfvars" -no-color

apply.yml – The following build spec runs our Terraform Apply

# /codebuild/apply.yml
version: 0.2
 
env:
  variables:
    TF_IN_AUTOMATION: "true"
 
phases:
  install:
    runtime-versions:
      python: 3.7
    commands:
      - "cd /usr/bin"
      - "curl -O https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip"
      - "unzip terraform_${TERRAFORM_VERSION}_linux_amd64.zip"
  build:
    commands:
      - cd "${CODEBUILD_SRC_DIR}"
      - terraform init -backend-config="envs/terraform_state.tfvars" -no-color
      - terraform apply -var-file="envs/variables.tfvars" -auto-approve -no-color

destroy.yml – The following build spec runs a Terraform Destroy. As you will have noted from above this is not part of the standard workflow as generally we won’t be destroying the resources but in the development process it’s likely we will want to teardown everything regularly.

# /codebuild/destroy.yml
version: 0.2
 
env:
  variables:
    TF_IN_AUTOMATION: "true"
 
phases:
  install:
    runtime-versions:
      python: 3.7
    commands:
      - "cd /usr/bin"
      - "curl -O https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip"
      - "unzip terraform_${TERRAFORM_VERSION}_linux_amd64.zip"
  build:
    commands:
      - cd "${CODEBUILD_SRC_DIR}"
      - terraform init -backend-config="envs/terraform_state.tfvars" -no-color
      - terraform destroy -var-file="envs/variables.tfvars" -auto-approve -no-color

Let kick off our Cloudformation template and see what gets created. I’ve used the following parameters for our template.

Once the stack has been provisioned we have our CodeCommit Repo.

We have our Terraform CodePipeline. Note the first step has failed. This is because the repo is empty and has no master branch. Let go ahead and populate it

Lets clone our repo and populate our content. We can get our S3 state bucket and CodeCommit repo from our Cloudformation Outputs.

$ git clone  https://git-codecommit.ap-southeast-2.amazonaws.com/v1/repos/app-sjramblings-infrastructure
Cloning into 'app-sjramblings-infrastructure'...
warning: You appear to have cloned an empty repository.

$ ls codebuild/
apply.yml		destroy.yml		validate_plan.yml

$ cat envs/terraform_state.tfvars 
bucket         = "sjramblings-ap-southeast-2-terraform"
key            = "infrastructure/terraform.tfstate"
region         = "ap-southeast-2"
dynamodb_table = "terraform-infrastructure-locks"
encrypt        = true

$ cat envs/variables.tfvars
# Empty file for vars later..
$

For our example we will add Terraform code to create an S3 bucket. We tell Terraform to store our resource state in S3 and create an S3 bucket as follows.

$ cat main.cf 
terraform {
  backend "s3" {}
}

resource "aws_s3_bucket" "b" {
  bucket = "sjramblings-bootstrap-terraform"
  acl    = "private"

  tags = {
    Name        = "My State Bucket"
  }
}

Now we commit and see the pipeline kick into action

$ git status
On branch master
Your branch is up to date with 'origin/master'.

Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

	new file:   codebuild/apply.yml
	new file:   codebuild/destroy.yml
	new file:   codebuild/validate_plan.yml
	new file:   envs/terraform_state.tfvars
	new file:   envs/variables.tfvars
	new file:   main.tf

$ git commit -m 'Initial Commit'
[master efc29fe] Initial Commit
 6 files changed, 75 insertions(+)
 create mode 100644 codebuild/apply.yml
 create mode 100644 codebuild/destroy.yml
 create mode 100644 codebuild/validate_plan.yml
 create mode 100644 envs/terraform_state.tfvars
 create mode 100644 envs/variables.tfvars
 create mode 100644 main.tf

$ git push
Enumerating objects: 11, done.
Counting objects: 100% (11/11), done.
Delta compression using up to 8 threads
Compressing objects: 100% (9/9), done.
Writing objects: 100% (10/10), 1.20 KiB | 1.21 MiB/s, done.
Total 10 (delta 2), reused 0 (delta 0)
To https://git-codecommit.ap-southeast-2.amazonaws.com/v1/repos/app-sjramblings-infrastructure
   521d7e8..efc29fe  master -> master

We see our commit has been picked up and triggered a pipeline execution.

Our GetSource and ValidatePlan have been successful so we have no errors in our syntax or current state, onto creating out S3 bucket.

As we can see from the CodeBuild run we have our S3 bucket via Terraform as requested.

This is obviously a very simple resource creation example but hopefully shows you the power of wrapping Terraform within AWS CodeCommit, CodeBuild & CodePipeline.

Hope this helps someone else!

Cheers