IaC Automation with Terraform and GitLab
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:
- Perform quality and security checks on code, stop pipeline if checks fail.
- Require manual approval from one or many team members.
- Infrastructure changes won’t be applied until approved.
- 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.