Infrastructure as code is cool. It’s certainly not the new kid on the block anymore, but has been a tool/methodology showing up in environments other than cutting edge hyperscalers and progressive software shops.

Combining IaC with Version Control & CI/CD

Placing IaC (Ansible, Terraform, etc.) in version control makes sense. After all, that’s where the devs place their code.

Take this a step further and we can attach a pipeline to our repository, giving us a platform to:

  1. Perform quality and security checks on code, stop pipeline if checks fail.
  2. Require manual approval from one or many team members.
    • Infrastructure changes won’t be applied until approved.
  3. Deploy the code from a single privileged identity.
    • Limit production access and define a clear deployment process (how & when).
    • Ensure your IaC is the configuration “source of truth”.

GitLab & Terraform in Action

Let’s take a look at a GitLab project that gives us a place to store code (Git) and run a CI/CD pipeline.

I want to pause here and mention it’s worth reading up on “Terraform backends” as the process outlined below is a bit manual and some parts could be better handled through use of a backend.

The Project:

The Terraform:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.27"
    }
  }

  required_version = ">= 0.14.9"
}

provider "aws" {
  profile = "default"
  region  = "us-east-1"
}

resource "aws_vpc" "vpc-lab01" {
  #ts:skip=AC_AWS_0369.test skip
  cidr_block       = "10.0.0.0/16"
  instance_tenancy = "default"

  tags = {
    Name = "vpc-lab01"
  }
}

resource "aws_subnet" "snet-loadbalancers-1a" {
  vpc_id     = aws_vpc.vpc-lab01.id
  cidr_block = "10.0.1.0/24"
  availability_zone = "us-east-1a"

  tags = {
    Name = "snet-loadbalancers-1a"
  }
}

resource "aws_subnet" "snet-loadbalancers-1b" {
  vpc_id     = aws_vpc.vpc-lab01.id
  cidr_block = "10.0.2.0/24"
  availability_zone = "us-east-1b"

  tags = {
    Name = "snet-loadbalancers-1b"
  }
}

resource "aws_subnet" "snet-webservers" {
  vpc_id     = aws_vpc.vpc-lab01.id
  cidr_block = "10.0.3.0/24"

  tags = {
    Name = "snet-webservers"
  }
}

resource "aws_subnet" "snet-databases" {
  vpc_id     = aws_vpc.vpc-lab01.id
  cidr_block = "10.0.4.0/24"

  tags = {
    Name = "snet-databases"
  }
}

resource "aws_internet_gateway" "gw-lab01" {
  vpc_id = aws_vpc.vpc-lab01.id

  tags = {
    Name = "gw-lab01"
  }
}

resource "aws_lb" "lb-lab01" {
  name               = "lb-lab01"
  internal           = false
  load_balancer_type = "application"
  subnets            = [aws_subnet.snet-loadbalancers-1a.id, aws_subnet.snet-loadbalancers-1b.id,]

  enable_deletion_protection = false
}

resource "aws_default_route_table" "rt-lab01" {
  default_route_table_id = aws_vpc.vpc-lab01.default_route_table_id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.gw-lab01.id
  }

  tags = {
    Name = "rt-lab01"
  }
}

The Pipeline:

before_script:
  - rm -rf .terraform
  - terraform --version
  - terraform init

stages:  
  - validate    
  - plan
  - apply

validate:
  stage: validate
  script:
    - terraform validate
    - terrascan scan -v -i terraform --find-vuln

plan:       
  stage: plan
  script:
    - terraform plan -out "planfile"
#    - terraform plan -destroy -out "planfile"
  dependencies:
    - validate
  artifacts:
    paths:
      - planfile

apply:       
  stage: apply
  script:
    - terraform apply -input=false "planfile"
#    - terraform apply -destroy -input=false "planfile"
    - git remote set-url origin https://git:$ACCESS_TOKEN@cicd.lab.local/cloud-cicd/aws-terraform.git && git config --global user.email "terraform@alawrence.tech" && git config --global user.name "terraform" 
    - git add *.tfstate && git commit --allow-empty -m "[terraform] commiting tfstate from $(git rev-parse --short HEAD)" && git push origin HEAD:main -o ci.skip
    - echo "Terraform state committed; commit-id `(git rev-parse --short HEAD)`"
  dependencies:
    - plan
  when: manual

I won’t elaborate on the structure of a GitLab pipeline, but I would like to cover each stage briefly below (and let you dig into the rest). Keep in mind some steps (such as pipeline runner config) aren’t covered.

Validate:

validate:
  stage: validate
  script:
    - terraform validate
    - terrascan scan -v -i terraform --find-vuln
  • The “terraform validate” command validates tf config files and associated syntax.
  • The “terrascan” command scans our “main.tf” IaC file for configuration vulnerabilities/risks (an example shown below, even though it is “skipped”).
  • If vulnerabilities are found, the pipeline will stop at this stage and simply not continue.

Plan:

plan:       
  stage: plan
  script:
    - terraform plan -out "planfile"
#    - terraform plan -destroy -out "planfile"
  dependencies:
    - validate
  artifacts:
    paths:
      - planfile
  • The “terraform plan” command creates a plan to deploy or destroy new infrastructure based on the existing “tfstate” file within the repo.
  • The “planfile” file is then attached to the pipeline as an artifact to be used in the “apply” stage.
  • This ensures that the exact changes outlined in the plan file are deployed, nothing more/less.

Apply:

apply:       
  stage: apply
  script:
    - terraform apply -input=false "planfile"
#    - terraform apply -destroy -input=false "planfile"
    - git remote set-url origin https://git:$ACCESS_TOKEN@cicd.lab.local/cloud-cicd/aws-terraform.git && git config --global user.email "terraform@alawrence.tech" && git config --global user.name "terraform" 
    - git add *.tfstate && git commit --allow-empty -m "[terraform] commiting tfstate from $(git rev-parse --short HEAD)" && git push origin HEAD:main -o ci.skip
    - echo "Terraform state committed; commit-id `(git rev-parse --short HEAD)`"
  dependencies:
    - plan
  when: manual
  • Note the “when: manual” definition, this enforces an approval step in the GitLab interface.
  • Once the stage is manually approved, the “terraform apply” command deploys the exact changes outlined in the “planfile”.
  • The “git” command steps are simply taking the updated “tfstate” file and committing/pushing them to the repo.
    • This step is important as we want to make sure we maintain state between pipeline runs.

Deployed AWS Infrastructure: