Pipeline IaC com Terraform e GitHub Actions
Tags: terraform, github, aws, devops
Published: 2022-06-22 20:28:14

#Introdução
Com a difusão da cultura DevOps nos últimos anos algumas práticas para entrega de novos produtos ou novas funcionalidades no ambiente produtivo mudaram bastante. Nesse artigo vou exemplificar em um pequeno laboratório um processo de pipeline CD/CD para entrega de uma infraestrutura utilizando **Terraform**, **GitHub Actions** e **AWS** como cloud pública.

#Terraform 🌎
[Terraform](https://www.terraform.io/docs) é uma ferramenta de infraestrutura como código (IaC) que permite criarmos recursos em clouds públicas ou privadas utilizando uma linguagem simples e declarativa, podendo assim reutilizar e versionar o código da sua infraestrutura assim como é feito com a aplicação. Você pode então usar um _workflow_ consistente para provisionar e gerenciar toda a sua infraestrutura ao longo de seu ciclo de vida.

#GitHub Actions 🤖
[GitHub Actions](https://docs.github.com/en/actions) é uma plataforma de integração contínua e entrega contínua (CI/CD) que permite automatizar sua compilação de código, testar e entregar de forma simples e rápida.

#Mão na massa! 💻 

A ideia do laboratório é provisionar uma instância EC2 com ip público executando um webserver [nginx](https://www.nginx.com/) rodando o famoso jogo da cobrinha 🐍.

###Preparando o GitHub para acessar a AWS com o Terraform
A primeira etapa que precisa ser feita é configura as _secrets_ **AWS_ACCESS_KEY_ID** e **AWS_SECRET_ACCESS_KEY** para que o **GitHub Actions** consiga acessar esses valores durante a execução do _workflow_ e ter acesso a sua conta da **AWS**.

Dentro do repositório vá em: **Settings -> Secrets -> Actions** e adicionei as chaves de acesso da AWS.

![pipeline-step-1](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1822hr94jvfljqt887os.gif)

###Criando os workflows do GitHub Actions
Para termos as actions do GitHub rodando de acordo com cada evento precisamos criar o diretório `.github/workflows` na raiz do projeto. Neste caso vamos criar 3 arquivos de workflow, um para o executar o `terraform plan` quando for criado um _pull request_, outro para executar o `terraform apply` quando o merge for feito na _branch main_ e o último e não menos importante para executar o `terraform destroy`, esse será executado manualmente quando for necessário destruir a infraestrutura.

Eu utilizei como base para a criação dos workflows a action oficial da [HashiCorp - Setup Terraform](https://github.com/marketplace/actions/hashicorp-setup-terraform)

**💡Dica:** No site [HashiCorp Learn](https://learn.hashicorp.com/tutorials/terraform/github-actions) temos um tutorial de como criar uma pipeline com GitHub Actions utilizando Terraform Cloud.

![pipeline-step-2](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/55b9z2i454yqjupv3ns7.png)

**plan.yml**
```yaml
name: "Plan"

on:
  pull_request:
      
env:
  AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

jobs:
  terraform:
    name: "Terraform"
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v1
        with:
          terraform_version: 0.15.5

      - name: Terraform Init
        id: init
        run: terraform init
      
      - name: Terraform Validate
        id: validate
        run: terraform validate -no-color

      - name: Terraform Plan
        id: plan
        run: terraform plan -no-color -input=false
        continue-on-error: true

      - uses: actions/github-script@v6
        if: github.event_name == 'pull_request'
        env:
          PLAN: "terraform\n${{ steps.plan.outputs.stdout }}"
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const output = `#### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\`
            #### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\`
            #### Terraform Validation 🤖\`${{ steps.validate.outcome }}\`
            #### Terraform Plan 📖\`${{ steps.plan.outcome }}\`
            <details><summary>Show Plan</summary>
            \`\`\`\n
            ${process.env.PLAN}
            \`\`\`
            </details>
            *Pushed by: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            })
      - name: Terraform Plan Status
        if: steps.plan.outcome == 'failure'
        run: exit 1

      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -auto-approve -input=false
```
**apply.yml**
```yaml
name: "Apply"

on:
  push:
    branches:
      - main
      
env:
  AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

jobs:
  terraform:
    name: "Terraform"
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v1
        with:
          terraform_version: 0.15.5

      - name: Terraform Init
        id: init
        run: terraform init
      
      - name: Terraform Validate
        id: validate
        run: terraform validate -no-color

      - name: Terraform Plan
        id: plan
        run: terraform plan -no-color -input=false
        continue-on-error: true

      - uses: actions/github-script@v6
        if: github.event_name == 'pull_request'
        env:
          PLAN: "terraform\n${{ steps.plan.outputs.stdout }}"
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const output = `#### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\`
            #### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\`
            #### Terraform Validation 🤖\`${{ steps.validate.outcome }}\`
            #### Terraform Plan 📖\`${{ steps.plan.outcome }}\`
            <details><summary>Show Plan</summary>
            \`\`\`\n
            ${process.env.PLAN}
            \`\`\`
            </details>
            *Pushed by: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            })
      - name: Terraform Plan Status
        if: steps.plan.outcome == 'failure'
        run: exit 1

      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -auto-approve -input=false
```
**destroy.yml**
```yaml
name: "Destroy"

on:
  workflow_dispatch:

env:
  AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

jobs:
  destroy:
    name: "terraform destroy"
    runs-on: ubuntu-latest    
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v1
        with:
          terraform_version: 0.15.5

      - name: Terraform Init
        id: init
        run: terraform init

      - name: Terraform Validate
        id: validate
        run: terraform validate -no-color

      - name: Terraform Destroy
        run: terraform destroy -auto-approve
```
###Código da infra no Terraform
Não vou me aprofundar muito nos detalhes do código do Terraform, isso pode ficar para outro artigo. Basicamente temos a criação de 3 recursos dentro da AWS são eles: **VPC**, **Security Group** e a **Instância EC2**.

Para a criação da VPC já com a subnet pública, zonas de disponibilidades (AZs) e NAT Gateway estou utilizando o módulo [vpc](https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/latest). Abaixo o trecho de código:

```hcl
module "vpc" {
  source = "terraform-aws-modules/vpc/aws"

  name = "snake-vpc"
  cidr = "10.200.0.0/16"

  azs             = ["us-east-1a", "us-east-1b"]
  public_subnets  = ["10.200.101.0/24", "10.200.102.0/24"]

  enable_nat_gateway = true

  tags = {
    Terraform   = "true"
    Environment = "prod"
  }
}
```

Para a criação do Security Group estou utilizando o [resource aws_security_group](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group), onde configuramos a liberação geral na porta 80, padrão HTTP. Abaixo o trecho de código:

```hcl
resource "aws_security_group" "game_snake_sg" {
  name        = "instances-snake-sg"
  description = "SG for Instances Snake Security Group"
  vpc_id      = module.vpc.vpc_id

  ingress {
    description = "Game Snake port 80"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  egress {
    from_port        = 0
    to_port          = 0
    protocol         = "-1"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }

  tags = {
    Terraform = "true"
    Environment = "prod"
  }
}
```

Para finalizar criamos a instância EC2 utilizando o módulo [ec2_instance](https://registry.terraform.io/modules/terraform-aws-modules/ec2-instance/aws/latest), o servidor será provisionado com a imagem _Amazon Linux 2_, tipo _t1.micro_, anexamos o _Security Group_ criando anteriormente, anexamos também a instância dentro da _subnet pública_ que foi criada junto com a VPC e por fim passamos um arquivo no _userdata_, que basicamente é um shell script que será executando quando a instância for iniciada.

```hcl
module "ec2_instance" {
  source = "terraform-aws-modules/ec2-instance/aws"

  name                   = "snake-game"
  ami                    = "ami-0cff7528ff583bf9a"
  instance_type          = "t1.micro"
  vpc_security_group_ids = [aws_security_group.game_snake_sg.id]
  subnet_id              = module.vpc.public_subnets[0]
  user_data              = file("userdata.sh")
  tags = {
    Name = "snake-game-ec2"
    Terraform = "true"
    Environment = "prod"
    Team = "gamer-development"
    Application = "snake-game"
    Language = "javascript"
  }
}
```
Desta forma o arquivo do terraform finalizado fica da seguinte maneira:

**main.tf**
```hcl
module "vpc" {
  source = "terraform-aws-modules/vpc/aws"

  name = "snake-vpc"
  cidr = "10.200.0.0/16"

  azs             = ["us-east-1a", "us-east-1b"]
  public_subnets  = ["10.200.101.0/24", "10.200.102.0/24"]

  enable_nat_gateway = true

  tags = {
    Terraform   = "true"
    Environment = "prod"
  }
}

resource "aws_security_group" "game_snake_sg" {
  name        = "instances-snake-sg"
  description = "SG for Instances Snake Security Group"
  vpc_id      = module.vpc.vpc_id

  ingress {
    description = "Game Snake port 80"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  egress {
    from_port        = 0
    to_port          = 0
    protocol         = "-1"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }

  tags = {
    Terraform = "true"
    Environment = "prod"
  }
}

module "ec2_instance" {
  source = "terraform-aws-modules/ec2-instance/aws"

  name                   = "snake-game"
  ami                    = "ami-0cff7528ff583bf9a"
  instance_type          = "t1.micro"
  vpc_security_group_ids = [aws_security_group.game_snake_sg.id]
  subnet_id              = module.vpc.public_subnets[0]
  user_data              = file("userdata.sh")
  tags = {
    Name = "snake-game-ec2"
    Terraform = "true"
    Environment = "prod"
    Team = "gamer-development"
    Application = "snake-game"
    Language = "javascript"
  }
}
```

###Enviando um Pull Request, fazendo o Merge e executando o deploy da infraestrutura 🚀

Vamos criar uma nova branch no projeto chamada `deployment` fazer uma alteração no arquivo `README.md` e criar um _Pull Request_. Assim que _Pull Request_ for criado o workflow **plan.yml** será disparado e poderemos verificar o que o Terraform irá provisionar na AWS.

####Pull Request
![pipeline-step-3](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/kiskfz9l34mwn538lgex.gif)

Veja que após a execução do workflow **plan.yml** dentro dos comentários da _Pull Request_ o GitHub Actions informa o resultado na execução, incrível né?! 🤩

![pipeline-step-4](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/je2jpmdxp5h4use2i139.png)

####Merge
Agora vamos aprovar o _Merge_ da _Pull Request_ para a `branch main`. Com essa estratégia é possível revisar o que exatamente o Terraform irá aplicar antes ser executado.

![pipeline-step-5](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/i1211ys4q1t1lm5niwmm.gif)

Após a execução podemos verificar no workflow **apply.yml** que tudo foi executado com sucesso.

![pipeline-step-6](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/b678r05hune01px8r972.gif)

####Validando a infraestrutura na AWS
Verificando a conta AWS vemos que toda a infraestrutura foi provisionada com sucesso e a aplicação do jogo da cobrinha 🐍 está online.

![pipeline-step-7](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xschg1gyf5kmmyskgo3c.gif)

###Executando o Destroy 🗑
Caso seja necessário deletar toda a infraestrutura que foi provisionada devemos executar o workflow **destroy.yml** manualmente.

![pipeline-step-8](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tglnt28mu3ins5ehri0u.gif)

Após a execução todos recursos na AWS foram deletados.

![pipeline-step-9](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ojxnbde73zcyj1s7l7g5.png)

#Conclusão
Utilizando pipelines CD/CD é possível entregar ambientes completos de forma automática e rápida. Se você notou criamos todos os recurso dentro da AWS sem precisar dar ao menos um clique dentro do painel administrativo. Use e abuse da infraestrutura como código (IaC). Espero que tenha gostado desse _hands-on_, até a próxima! ✌🏼