Skip to main content

Usando Terraform no Backstage

Como o terraform é o IaC mais usado é importante criar um exemplo sobre isso. No futuro faremos uma demo completa usando o Crossplane.

Se alguém precisa de um recurso de infra estrutura na cloud podemos oferecer através de um template que cria as entradas para um terraform.

Vou criar um repositório que conterá este template. Vou usar o template generator que criei anteriormente só para gerar o repositório e depois irei trabalhar em cima do código. Se quiser crie um repositório que será importado.

Repositório de referência

git clone [email protected]:davidpuziol/backstage-template-aws-vm.git

cd backstage-template-aws-vm

tree
.
├── catalog-info.yaml
├── docs
│   └── index.md
└── template
└── main

2 directories, 3 files

Então já temos o nosso esqueleto e o que precisamos fazer é trabalhar em cima da template e colocar os arquivos de terraform ali para sofrerem o scaffolder.

Um detalhe importante é que nesse template vamos fazer da forma mais simples possível passando tudo que é necessário execução, não é a melhor forma de fazer, é só para praticar.

Após trabalhar em cima do template vamos registrar alguns detalhes.

tree -a -I '.git'
.
├── catalog-info.yaml # Definições do template
├── docs
│ └── index.md # Documentação
└── template
├── catalog-info.yaml # Isso será o nosso resource
├── .github
│ └── workflows
│ └── instance.yml # Github Action que irá rodar o terraform
├── main.tf # Arquivos terraform
├── provider.tf
├── variables.tf
└── versions.tf

As variáveis necessárias estão em variables.tf. Elas serão definidas durante o comando apply usando a flag -var. Veremos mais adiante.

variable "access_key" {
type = string
}

variable "secret_key" {
type = string
}

variable "awsRegion" {
type = string
}

variable "instanceName" {
type = string
}

variable "instanceType" {
type = string
}

O cloud provider AWS precisa das credênciais que precisam estar nas variáveis de ambiente.

provider "aws" {
region = var.awsRegion
access_key = var.access_key
secret_key = var.secret_key
}

O catalog-info.yaml dentro do template representará a vm como um Resouce dentro do sistema. Uma coisa importante ao mensionar é que o caso utilize um type customizado é necessário alterar as configurações no Backstage para que mostre a aba CI|CD.


apiVersion: backstage.io/v1alpha1
kind: Resource
metadata:
name: ${{ values.name }}
description: ${{ values.description }} Virtual Machine EC2
annotations:
# A url precisa estar no formato owner/project e já esta sendo corrigida durante a passagem do parâmetro.
# Veremos mais pra frente.
github.com/project-slug: ${{ values.urlRepo }}
tags: # Procure usar tags para filtrar o resources
- ${{ values.principalTag }}
{%- for tag in values.otherTags %}
- ${{ tag }}
{%- endfor %}
spec:
type: service
owner: ${{ values.owner }}

Agora vamos ao template. Parte desse template foi criada a partir do gerador de template e modificado para esse cenário.

apiVersion: scaffolder.backstage.io/v1beta3
kind: Template

metadata:
name: aws-vm
title: aws-vm Template
description: Base template for creating aws-vm-based projects
annotations:
backstage.io/techdocs-ref: dir:.

tags: # Tags do template
- infra
- terraform

spec:

owner: group:default/team-idp
type: resource
lifecycle: production

parameters:

- title: General Inputs
required:
- owner
- principalTag
properties:
owner:
title: Owner
type: string
description: Owner of the component
ui:field: OwnerPicker
ui:options:
allowArbitraryValues: false
catalogFilter:
kind: Group
spec.type: team
principalTag: # Não é obrigaŕio nenhuma tag, colocamos para forçar os filtros depois.
title: General Tag
type: string
description: Principal Tag
default: infra
enum:
- infra
- general
ui:help: 'Used to categorize the template'
otherTags:
description: List of other tags (only lowercase letters and hyphen allowed)
type: array
items:
type: string
pattern: "^[a-z]+$"
ui:field: MultiTagInputField
# Ambas as tags serão injjetadas depois no resource que mapeará a vm para dentro do backstage.

### OS DADOS QUE PRECISAMOS PARA CRIAÇÃO DA VM
- title: EC2 Instance Creation
required:
- InstanceName
- Region
- InstanceType
properties:
InstanceName:
title: Instance Name será o mesmo nome do recurso criado no Backstage.
type: string
description: Instance name that will be created
ui:autofocus: true

Region:
title: AWS Region
type: string
description: Name of the region where you want to create your create-ec2-instance eg:- us-east-1, ap-south-1 etc.
InstanceType:
title: Type of Instance.
type: string
description: Type of the instance that you want to deploy, for eg:- t2.medium, t3.medium etc.
enum:
- t2.medium
- t2.small
- t2.micro
- t3.medium

Action: # O pipeline pode rodar apply ou destroy
title: Actions
type: string
description: What action do you want to perform? Create or delete?
enum:
- apply
- destroy
ui:help: 'Pipeline will run this'

# Como falei anteriormente esse método precisa passar credencnais para ficar armazenada no repositório. Não gosto disso, mas para uma explicação simples serve.
- title: AWS Credentials
required:
- AWSKey
- AWSSecret
properties:
AWSKey:
title: AWS Access Key
type: string
description: Your AWS Access Key to be a Secret in Repo.
ui:autofocus: true
ui:field: Secret
AWSSecret:
title: AWS Secret Key
type: string
description: Yout AWS Secret Key to be a Secret in Repo.
ui:autofocus: true
ui:field: Secret

- title: Choose a Repository Location
required:
- repoUrl
properties:
repoUrl:
title: Location of the repository
type: string
ui:field: RepoUrlPicker
ui:options:
allowedHosts:
- github.com

steps:
# Preenchedo as variáveis
- id: fetch-base
name: Fetch Base
action: fetch:template
input:
url: ./template
values:
name: ${{ parameters.InstanceName }}
owner: ${{ parameters.owner }}
principalTag: ${{ parameters.principalTag }}
otherTags: ${{ parameters.otherTags }}
description: This is ${{ parameters.InstanceName }}

# Criando o repositório
- id: publish
name: Publish
action: publish:github
input:
allowedHosts: ['github.com']
description: This is ${{ parameters.InstanceName }}
repoUrl: ${{ parameters.repoUrl }}
repoVisibility: private
defaultBranch: main
secrets:
awsAccessKey: ${{ parameters.AWSKey }}
awsSecretKey: ${{ parameters.AWSSecret }}

# Rodando a pipeline.
# Nesse momento estamos passando os valores de input esperados pela pipeline para definir as variaveis e as credênciais da AWS será pegas pela secret.
- id: github-action
name: Starting GitHub action
action: github:actions:dispatch
input:
workflowId: instance.yml
repoUrl: ${{ parameters.repoUrl }}
branchOrTagName: main
workflowInputs:
instanceName: ${{ parameters.InstanceName }}
awsRegion: ${{ parameters.Region }}
instanceType: ${{ parameters.InstanceType }}
action: ${{ parameters.Action }}

# Registrando no Backstage
- id: register
name: Register
action: catalog:register
input:
repoContentsUrl: ${{ steps['publish'].output.repoContentsUrl }}
catalogInfoPath: '/catalog-info.yaml'

# Saída.
output:
links:
- title: Repository
url: ${{ steps['publish'].output.remoteUrl }}
- title: Open in catalog
icon: catalog
entityRef: ${{ steps['register'].output.entityRef }}

Vou repetir código nos jobs para ficar mais fácil o entendimento neste momento. O arquivo .github/workdflows/instance.yml será disparado depois que for publicado o repositório vamos conferir.

Esse workflow poderá ser disparado manualmente centro do CI/CD no recurso.

name: Create an ec2 instance
# Esse workflow é para ser disparado manualmente
on:
workflow_dispatch:
# A entrada das variáveis
inputs:
instanceName:
description: 'Name of the AWS Instance'
required: true
awsRegion:
description: 'AWS Region for the instance'
required: true
instanceType:
description: 'AWS instance type'
required: true
action:
description: 'Action to perform (apply/destroy)'
required: true
jobs:
apply_ec2: # Só será executado se a action for apply
runs-on: ubuntu-latest

if: ${{ github.event.inputs.action == 'apply' }}
steps:
# Pegamos o código
- name: Checkout code
uses: actions/checkout@v2

# Configurando as variáveis de ambiente para as credênciais
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWSACCESSKEY }}
aws-secret-access-key: ${{ secrets.AWSSECRETKEY }}
# Damos umas checadas.
- name: Terraform Init
run: terraform init
working-directory: .

- name: Terraform Format
run: terraform fmt
working-directory: .

- name: Terraform Validate
run: terraform validate
working-directory: .

# Aqui é onde iremos aplicar passando os valores das variáveis.
- name: terraform apply
run: terraform apply -var instanceName=${{ github.event.inputs.instanceName }} -var awsRegion=${{ github.event.inputs.awsRegion }} -var instanceType=${{ github.event.inputs.instanceType }} -auto-approve
working-directory: .

destroy_instance: # Só será executado se a action for destroy
runs-on: ubuntu-latest

if: ${{ github.event.inputs.action == 'destroy' }}

steps:
- name: Checkout code
uses: actions/checkout@v2

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWSACCESSKEY }}
aws-secret-access-key: ${{ secrets.AWSSECRETKEY }}

- name: Terraform Init
run: terraform init
working-directory: .

- name: Terraform FMT
run: terraform fmt
working-directory: .

# A única diferença é que aqui vamos destruir tudo
- name: Terraform Destroy
run: terraform destroy -var instanceName=${{ github.event.inputs.instanceName }} -var awsRegion=${{ github.event.inputs.awsRegion }} -auto-approve
working-directory: .

alt text

Outras estratégias

A estratégia acima utiliza o github actions para aplicar e envolve ter no repositório as secrets configuradas. A solução seria injetar essas secrets usando um vault para eliminar do repositório.

Uma outra solução seria usar uma action que fizesse o pull request para dentro de um repositório que o Atlantis toma conta e somente depois do pull request ser aceito então o próprio runner ativado pelo Atlantis irá aplicar o terraform.

alt text

A primeira abordagem é mais self-service, mas precisa ser melhor elaborada para uso das secrets. A segunda abordagem mantém mais o controle uma vez que alguém precisa aceitar o pull request para que o Atlantis faça o seu serviço.

A idéia que mais gosto é usar o Crossplane junto com o ArgoCD. Toda as credenciais ficam no kubernetes e não precisamos de pipelines nem Atlantis. Ainda farei um exemplo disso melhor no futuro próximo.