De ce Terraform necesită o conductă dedicată

Fugi terraform apply manual de pe laptopul unui dezvoltator este cel mai periculos model din infrastructură. Fără un flux de lucru automatizat, probleme tipice sunt: fișierul de stare nu este actualizat, acreditările locale diferite de mediul CI, nicio revizuire a planului înainte de a aplica, nicio urmă despre cine a aplicat ce și când.

O conductă profesională Terraform rezolvă toate aceste probleme: planul vine generat automat, revizuit în Pull Request, iar aplicarea are loc numai ulterior aprobare, cu jurnalele permanente în sistemul de versiuni. În acest articol Construim această conductă cu două abordări: Acțiuni GitHub pentru echipe mic şi Atlantida pentru echipe medii și mari.

Ce vei învăța

  • Conducta GitHub Actions: fmt, validate, tflint, plan on PR, and apply on merge
  • Gestionare securizată a acreditărilor în cloud cu OIDC (fără secrete de lungă durată)
  • Atlantis: instalare, atlantis.yaml și flux de lucru PR colaborativ
  • Terraform Cloud / HCP Terraform ca alternativă gestionată
  • Detectare declin: lucrare cron pentru a detecta discrepanțe între infrastructura de stat și cea reală
  • Strategie matrice pentru conducte multi-mediu (dezvoltare/staging/prod)
  • Notificări Slack/Teams cu privire la planuri și alerte de deriva

Structura depozitului Terraform

Înainte de a construi conducta, aveți nevoie de o structură clară a depozitului. Modelul cea mai comună este separarea după mediu în directoare distincte, cu module partajate într-un director separat.

terraform-infra/
  modules/
    vpc/
      main.tf
      variables.tf
      outputs.tf
    eks-cluster/
    rds-postgres/
  environments/
    dev/
      main.tf         # Usa i moduli con variabili dev
      variables.tf
      terraform.tfvars
      backend.tf      # Backend S3 per dev
    staging/
      main.tf
      terraform.tfvars
      backend.tf
    production/
      main.tf
      terraform.tfvars
      backend.tf
  .terraform-version  # tfenv: specifica la versione di Terraform
  .tflint.hcl         # Configurazione tflint
  .github/
    workflows/
      terraform.yml
# .terraform-version
# Usato da tfenv per installare la versione corretta
1.7.5

Acțiuni GitHub: conductă completă

Conducta GitHub Actions pe care o construim are două fluxuri de lucru: unul declanșat de solicitări de tragere (lint + plan) și unul declanșat de merge pe principal (aplicare).

# .github/workflows/terraform.yml
name: Terraform CI/CD

on:
  push:
    branches:
      - main
    paths:
      - 'environments/**'
      - 'modules/**'
  pull_request:
    branches:
      - main
    paths:
      - 'environments/**'
      - 'modules/**'

permissions:
  contents: read
  pull-requests: write  # Per commentare il plan sul PR
  id-token: write       # Per OIDC authentication con AWS

env:
  TF_VERSION: '1.7.5'
  TFLINT_VERSION: 'v0.50.0'

jobs:
  # Job 1: Lint e validazione statica (tutti gli ambienti in parallelo)
  lint-validate:
    name: Lint & Validate (${{ matrix.environment }})
    runs-on: ubuntu-latest
    strategy:
      matrix:
        environment: [dev, staging, production]
      fail-fast: false

    defaults:
      run:
        working-directory: environments/${{ matrix.environment }}

    steps:
      - uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Setup TFLint
        uses: terraform-linters/setup-tflint@v4
        with:
          tflint_version: ${{ env.TFLINT_VERSION }}

      - name: Terraform Format Check
        run: terraform fmt -check -recursive
        working-directory: .

      - name: Terraform Init (solo backend locale per lint)
        run: terraform init -backend=false

      - name: Terraform Validate
        run: terraform validate

      - name: TFLint
        run: |
          tflint --init
          tflint --format compact

  # Job 2: Plan su Pull Request
  plan:
    name: Plan (${{ matrix.environment }})
    runs-on: ubuntu-latest
    needs: lint-validate
    if: github.event_name == 'pull_request'
    strategy:
      matrix:
        environment: [dev, staging]  # Non pianifichiamo prod su ogni PR
      fail-fast: false

    defaults:
      run:
        working-directory: environments/${{ matrix.environment }}

    steps:
      - uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

      # OIDC: nessun secret long-lived necessario
      - name: Configure AWS Credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActionsterraform-${{ matrix.environment }}
          aws-region: eu-west-1

      - name: Terraform Init
        run: terraform init

      - name: Terraform Plan
        id: plan
        run: |
          terraform plan -no-color -out=tfplan 2>&1 | tee plan_output.txt
          echo "PLAN_EXIT_CODE=${PIPESTATUS[0]}" >> "$GITHUB_ENV"

      # Commenta il plan sul Pull Request
      - name: Comment Plan on PR
        uses: actions/github-script@v7
        if: always()
        with:
          script: |
            const fs = require('fs');
            const planOutput = fs.readFileSync('environments/${{ matrix.environment }}/plan_output.txt', 'utf8');
            const truncated = planOutput.length > 65000
              ? planOutput.substring(0, 65000) + '\n... (truncated)'
              : planOutput;

            const body = `## Terraform Plan - ${{ matrix.environment }}

            \`\`\`
            ${truncated}
            \`\`\`

            *Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`;

            // Cerca commenti precedenti dello stesso workflow e aggiorna
            const comments = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });

            const existingComment = comments.data.find(c =>
              c.user.login === 'github-actions[bot]' &&
              c.body.includes('Terraform Plan - ${{ matrix.environment }}')
            );

            if (existingComment) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: existingComment.id,
                body,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body,
              });
            }

  # Job 3: Apply su merge in main
  apply:
    name: Apply (${{ matrix.environment }})
    runs-on: ubuntu-latest
    needs: lint-validate
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    strategy:
      # Apply sequenziale: prima dev, poi staging, poi production
      max-parallel: 1
      matrix:
        environment: [dev, staging, production]

    environment:
      name: ${{ matrix.environment }}  # Richiede approvazione manuale per production

    defaults:
      run:
        working-directory: environments/${{ matrix.environment }}

    steps:
      - uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Configure AWS Credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActionsterraform-${{ matrix.environment }}
          aws-region: eu-west-1

      - name: Terraform Init
        run: terraform init

      - name: Terraform Apply
        run: terraform apply -auto-approve -no-color

OIDC: Acreditări fără secrete de lungă durată

Cea mai bună practică modernă pentru autentificarea GitHub Actions la AWS este OIDC (OpenID Connect): GitHub generează un token JWT semnat pe care AWS acceptă fără să fii nevoit să memorezi AWS_ACCESS_KEY_ID în secrete.

# Configurazione IAM Role per GitHub Actions OIDC
# Crea questo con Terraform stesso (bootstrap)

# iam.tf
resource "aws_iam_openid_connect_provider" "github" {
  url = "https://token.actions.githubusercontent.com"

  client_id_list = ["sts.amazonaws.com"]

  thumbprint_list = [
    "6938fd4d98bab03faadb97b34396831e3780aea1"
  ]
}

resource "aws_iam_role" "github_actions_terraform" {
  for_each = toset(["dev", "staging", "production"])

  name = "GitHubActionsterraform-${each.key}"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = {
        Federated = aws_iam_openid_connect_provider.github.arn
      }
      Action = "sts:AssumeRoleWithWebIdentity"
      Condition = {
        StringEquals = {
          "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
        }
        StringLike = {
          # Permetti solo da questo repo specifico
          "token.actions.githubusercontent.com:sub" = "repo:myorg/terraform-infra:*"
        }
      }
    }]
  })
}

resource "aws_iam_role_policy_attachment" "terraform_permissions" {
  for_each = aws_iam_role.github_actions_terraform

  role       = each.value.name
  policy_arn = "arn:aws:iam::aws:policy/PowerUserAccess"
  # In produzione usa policy più ristrette con least-privilege
}

Atlantis: Planifică/Aplică colaborativ prin PR

Atlantida este un server care ascultă webhook-uri GitHub/GitLab/Bitbucket și răspunde la comentariile din PR. Când scrii atlantis plan intr-un comentariu, Atlantis execută terraform plan și răspunde cu rezultatul. Aceasta creează o flux de lucru complet urmărit și revizuibil în PR.

# atlantis.yaml - Configurazione nella root del repository
version: 3

# Abilita auto-planning: piano automatico quando il PR tocca file .tf
automerge: false
parallel_plan: true
parallel_apply: false

projects:
  - name: dev
    dir: environments/dev
    workspace: default
    autoplan:
      when_modified: ["*.tf", "*.tfvars", "../../modules/**/*.tf"]
      enabled: true
    apply_requirements:
      - approved       # Richiede almeno 1 approvazione
      - mergeable      # Il PR deve essere mergeable

  - name: staging
    dir: environments/staging
    workspace: default
    autoplan:
      when_modified: ["*.tf", "*.tfvars", "../../modules/**/*.tf"]
      enabled: true
    apply_requirements:
      - approved
      - mergeable

  - name: production
    dir: environments/production
    workspace: default
    autoplan:
      enabled: false    # Non pianifica automaticamente in prod
    apply_requirements:
      - approved        # Richiede almeno 2 approvazioni
      - mergeable
    allowed_override_dentists:  # Solo questi utenti possono fare apply in prod
      - alice
      - bob
# Comandi Atlantis nel Pull Request (come commento)

# Esegui plan per il progetto 'dev'
atlantis plan -p dev

# Esegui plan per tutti i progetti modificati
atlantis plan

# Applica dopo approvazione del PR
atlantis apply -p dev

# Annulla un plan in corso
atlantis unlock

# Mostra lo stato attuale
atlantis status
# docker-compose.yml per Atlantis self-hosted
version: '3.8'

services:
  atlantis:
    image: ghcr.io/runatlantis/atlantis:v0.27.0
    ports:
      - "4141:4141"
    environment:
      ATLANTIS_GH_USER: atlantis-bot           # GitHub user del bot
      ATLANTIS_GH_TOKEN: ${GH_TOKEN}            # Personal Access Token del bot
      ATLANTIS_GH_WEBHOOK_SECRET: ${WEBHOOK_SECRET}
      ATLANTIS_REPO_ALLOWLIST: "github.com/myorg/terraform-infra"
      ATLANTIS_AUTOMERGE: "false"
      ATLANTIS_WRITE_GIT_CREDS: "true"

      # Credenziali AWS (oppure usa IAM Instance Profile)
      AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
      AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
      AWS_DEFAULT_REGION: eu-west-1

    volumes:
      - atlantis-data:/home/atlantis
      - ./atlantis.yaml:/home/atlantis/atlantis.yaml:ro

volumes:
  atlantis-data:

Detecție de derive: Cron Job pentru a detecta discrepanțe

Il derivă este momentul în care infrastructura reală diferă din ceea ce este descris în fișierul de stare Terraform. Are loc deriva când cineva editează manual resurse din consola AWS, când o resursă este modificat printr-un proces extern sau atunci când un API cloud modifică comportamentul.

# .github/workflows/drift-detection.yml
name: Terraform Drift Detection

on:
  schedule:
    - cron: '0 8 * * 1-5'  # Ogni giorno lavorativo alle 8:00 UTC
  workflow_dispatch:          # Esecuzione manuale

permissions:
  contents: read
  issues: write               # Per aprire issue se drift rilevato
  id-token: write

jobs:
  detect-drift:
    name: Detect Drift (${{ matrix.environment }})
    runs-on: ubuntu-latest
    strategy:
      matrix:
        environment: [dev, staging, production]
      fail-fast: false

    defaults:
      run:
        working-directory: environments/${{ matrix.environment }}

    steps:
      - uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: '1.7.5'

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActionsReadOnly-${{ matrix.environment }}
          aws-region: eu-west-1

      - name: Terraform Init
        run: terraform init -no-color

      - name: Terraform Plan (Drift Detection)
        id: drift
        run: |
          # Esci con codice 0 se no changes, 1 se error, 2 se changes (drift)
          terraform plan -detailed-exitcode -no-color 2>&1 | tee drift_output.txt
          echo "EXIT_CODE=${PIPESTATUS[0]}" >> "$GITHUB_ENV"

      - name: Alert on Drift
        if: env.EXIT_CODE == '2'
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const output = fs.readFileSync('environments/${{ matrix.environment }}/drift_output.txt', 'utf8');

            // Crea un issue GitHub per il drift rilevato
            await github.rest.issues.create({
              owner: context.repo.owner,
              repo: context.repo.repo,
              title: '[DRIFT] Infrastructure drift detected in ${{ matrix.environment }}',
              body: `## Drift rilevato in ambiente: ${{ matrix.environment }}

            Data: ${new Date().toISOString()}

            \`\`\`
            ${output.substring(0, 60000)}
            \`\`\`

            **Azione richiesta:** Analizza le modifiche e riconcilia lo state.
            - Se la modifica è intenzionale: aggiorna il codice Terraform e fai un PR
            - Se è una modifica non autorizzata: ripristina con \`terraform apply\``,
              labels: ['infrastructure', 'drift', '${{ matrix.environment }}'],
            });

      - name: Notify Slack on Drift
        if: env.EXIT_CODE == '2'
        uses: slackapi/slack-github-action@v1.26.0
        with:
          payload: |
            {
              "text": ":warning: Terraform drift rilevato in *${{ matrix.environment }}*!\nControlla il <${{ github.server_url }}/${{ github.repository }}/issues|GitHub Issue> creato.",
              "channel": "#infra-alerts"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

TFLint: Advanced Listing pentru HCL

# .tflint.hcl - Configurazione TFLint
plugin "aws" {
  enabled = true
  version = "0.29.0"
  source  = "github.com/terraform-linters/tflint-ruleset-aws"
}

plugin "azurerm" {
  enabled = true
  version = "0.26.0"
  source  = "github.com/terraform-linters/tflint-ruleset-azurerm"
}

# Regole generali
rule "terraform_deprecated_interpolation" {
  enabled = true
}

rule "terraform_documented_outputs" {
  enabled = true
}

rule "terraform_documented_variables" {
  enabled = true
}

rule "terraform_naming_convention" {
  enabled = true
  format  = "snake_case"
}

rule "terraform_required_providers" {
  enabled = true
}

rule "terraform_required_version" {
  enabled = true
}

# Regole AWS specifiche
rule "aws_instance_invalid_type" {
  enabled = true
}

rule "aws_instance_previous_type" {
  enabled = true
}

Terraform Cloud: Alternativă gestionată

Dacă nu vrei să gestionezi singur Atlantida, HCP Terraform (fost Terraform Cloud) oferă plan/aplicare la distanță, management de stat, SSO și politică (Sentinel) ca serviciu.

# terraform.tf - Configurazione HCP Terraform come backend
terraform {
  required_version = "~> 1.7"

  # HCP Terraform (gestisce state, plan e apply in cloud)
  cloud {
    organization = "my-organization"

    workspaces {
      # Workspace per environment con tagging
      tags = ["terraform-infra", "aws"]
    }
  }

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

# Con HCP Terraform:
# - Nessun backend S3 da gestire
# - Plan/Apply avvengono nei server HCP (non in CI)
# - Variabili e secrets gestiti nel workspace
# - VCS integration: trigger automatico da PR
# - Team-based access control
# - Run history permanente

Cele mai bune practici pentru conductele Terraform în producție

Lista de verificare a conductei Terraform

  • STATELE UNITE ALE AMERICII OIDC în loc de acreditările AWS de lungă durată în secretele CI
  • Fugi terraform fmt -check pentru a bloca PR cu cod neformatat
  • STATELE UNITE ALE AMERICII terraform validate înainte de planul de a surprinde erorile de sintaxă
  • Salvați planul binar (-out=tfplan) și folosește-l pentru aplicare, nu se regenerează
  • Executați aplicația în ordine secvențială (dev → staging → prod), nu în paralel
  • Solicitați aprobare manuală pentru producție cu regulile de protecție a mediului GitHub
  • Implementați detectarea derivei programată pentru a detecta modificările manuale
  • Adăuga prevent_destroy = true asupra resurselor critice de stat
  • Înregistrați toate planurile și aplicați cu marca temporală și autor în jurnalul CI

Concluzii și pașii următori

O conductă Terraform bine proiectată este fundamentul unei infrastructuri sigur și urmăribil. Fluxul de lucru Plan-Review-Apply în PR asigură că niciunul modificarea infrastructurii are loc fără revizuire, în timp ce detectarea derivei Obțineți schimbări neplanificate înainte ca acestea să devină probleme.

Următoarele articole din serie

  • Articolul 5: Testare IaC — Terratest, Terraform Test Native e Testarea contractelor: Cum să scrieți teste automate pentru modulele dvs. Terraform.
  • Articolul 6: IaC Security — Checkov, Trivy și OPA Policy-as-Code: integrează scanarea de securitate în poarta de pre-aplicare a conductei.