Skip to content

CI/CD Pipelines pour IaC

Introduction

L'intégration et le déploiement continu (CI/CD) pour l'Infrastructure as Code automatisent la validation, les tests et le déploiement des changements d'infrastructure. Cette approche GitOps garantit traçabilité et reproductibilité.

Prérequis

Points à apprendre

Architecture GitOps

graph TB
    DevOps["DevOps Engineer"]

    subgraph "Git Repository"
        Main["main branch<br/>Production"]
        Develop["develop branch<br/>Staging"]
        Feature["feature branches<br/>Development"]
    end

    subgraph "CI/CD Platform"
        Pipeline["Pipeline<br/>GitLab CI<br/>Lint, Plan, Apply"]
        Artifacts["Artifacts<br/>Plans, Reports"]
    end

    subgraph "OpenStack Environments"
        Prod["Production"]
        Staging["Staging"]
        Dev["Development"]
    end

    DevOps -->|Push<br/>git| Feature
    Feature -->|Merge Request| Develop
    Develop -->|Merge Request| Main

    Feature -->|Trigger<br/>webhook| Pipeline
    Develop -->|Trigger| Pipeline
    Main -->|Trigger| Pipeline

    Pipeline -->|Deploy<br/>feature/*| Dev
    Pipeline -->|Deploy<br/>develop| Staging
    Pipeline -->|Deploy<br/>main| Prod

Pipeline GitLab CI

# .gitlab-ci.yml

stages:
  - validate
  - plan
  - apply
  - configure
  - test
  - destroy

variables:
  TF_ROOT: ${CI_PROJECT_DIR}/terraform/environments
  ANSIBLE_ROOT: ${CI_PROJECT_DIR}/ansible
  TF_STATE_NAME: ${CI_PROJECT_NAME}
  # Variables OpenStack (depuis CI/CD Variables)
  # OS_AUTH_URL, OS_USERNAME, OS_PASSWORD, etc.

.terraform_base:
  image: hashicorp/terraform:1.6
  before_script:
    - cd ${TF_ROOT}/${ENVIRONMENT}
    - terraform init
      -backend-config="address=${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${TF_STATE_NAME}-${ENVIRONMENT}"
      -backend-config="lock_address=${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${TF_STATE_NAME}-${ENVIRONMENT}/lock"
      -backend-config="unlock_address=${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${TF_STATE_NAME}-${ENVIRONMENT}/lock"
      -backend-config="username=gitlab-ci-token"
      -backend-config="password=${CI_JOB_TOKEN}"
      -backend-config="lock_method=POST"
      -backend-config="unlock_method=DELETE"
      -backend-config="retry_wait_min=5"

# ==================== VALIDATE ====================

validate:lint:
  stage: validate
  image: hashicorp/terraform:1.6
  script:
    - terraform fmt -check -recursive ${CI_PROJECT_DIR}/terraform
    - terraform validate ${CI_PROJECT_DIR}/terraform/modules/network
    - terraform validate ${CI_PROJECT_DIR}/terraform/modules/compute
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

validate:security:
  stage: validate
  image:
    name: aquasec/tfsec:latest
    entrypoint: [""]
  script:
    - tfsec ${CI_PROJECT_DIR}/terraform --format junit > tfsec-report.xml
  artifacts:
    reports:
      junit: tfsec-report.xml
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

validate:ansible:
  stage: validate
  image: cytopia/ansible-lint:latest
  script:
    - cd ${ANSIBLE_ROOT}
    - ansible-lint playbooks/
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

# ==================== PLAN ====================

.plan_template:
  extends: .terraform_base
  stage: plan
  script:
    - terraform plan -out=tfplan -input=false
    - terraform show -json tfplan > plan.json
  artifacts:
    paths:
      - ${TF_ROOT}/${ENVIRONMENT}/tfplan
      - ${TF_ROOT}/${ENVIRONMENT}/plan.json
    expire_in: 1 week

plan:development:
  extends: .plan_template
  variables:
    ENVIRONMENT: development
  rules:
    - if: $CI_COMMIT_BRANCH =~ /^feature\//
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

plan:staging:
  extends: .plan_template
  variables:
    ENVIRONMENT: staging
  rules:
    - if: $CI_COMMIT_BRANCH == "develop"

plan:production:
  extends: .plan_template
  variables:
    ENVIRONMENT: production
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

# ==================== APPLY ====================

.apply_template:
  extends: .terraform_base
  stage: apply
  script:
    - terraform apply -input=false tfplan
    - terraform output -json > outputs.json
  artifacts:
    paths:
      - ${TF_ROOT}/${ENVIRONMENT}/outputs.json
    expire_in: 1 week

apply:development:
  extends: .apply_template
  variables:
    ENVIRONMENT: development
  needs:
    - plan:development
  rules:
    - if: $CI_COMMIT_BRANCH =~ /^feature\//
      when: manual
  environment:
    name: development
    on_stop: destroy:development

apply:staging:
  extends: .apply_template
  variables:
    ENVIRONMENT: staging
  needs:
    - plan:staging
  rules:
    - if: $CI_COMMIT_BRANCH == "develop"
      when: manual
  environment:
    name: staging

apply:production:
  extends: .apply_template
  variables:
    ENVIRONMENT: production
  needs:
    - plan:production
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual
  environment:
    name: production

# ==================== CONFIGURE ====================

.configure_template:
  stage: configure
  image: cytopia/ansible:latest
  before_script:
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - ssh-keyscan -H $(cat ${TF_ROOT}/${ENVIRONMENT}/outputs.json | jq -r '.floating_ips.value[]') >> ~/.ssh/known_hosts
  script:
    - cd ${ANSIBLE_ROOT}
    - |
      # Générer l'inventaire depuis les outputs Terraform
      cat ${TF_ROOT}/${ENVIRONMENT}/outputs.json | \
        jq -r '.ansible_inventory.value | to_entries | .[] | "\(.key) ansible_host=\(.value.ansible_host)"' \
        > inventory/hosts_${ENVIRONMENT}.ini
    - ansible-playbook -i inventory/hosts_${ENVIRONMENT}.ini playbooks/site.yml
      --extra-vars "environment=${ENVIRONMENT}"

configure:development:
  extends: .configure_template
  variables:
    ENVIRONMENT: development
  needs:
    - apply:development
  rules:
    - if: $CI_COMMIT_BRANCH =~ /^feature\//
      when: manual

configure:staging:
  extends: .configure_template
  variables:
    ENVIRONMENT: staging
  needs:
    - apply:staging
  rules:
    - if: $CI_COMMIT_BRANCH == "develop"

configure:production:
  extends: .configure_template
  variables:
    ENVIRONMENT: production
  needs:
    - apply:production
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual

# ==================== TEST ====================

test:integration:
  stage: test
  image: curlimages/curl:latest
  script:
    - |
      ENDPOINTS=$(cat ${TF_ROOT}/${ENVIRONMENT}/outputs.json | jq -r '.floating_ips.value[]')
      for ip in $ENDPOINTS; do
        echo "Testing http://$ip/health"
        curl -f http://$ip/health || exit 1
      done
  rules:
    - if: $CI_COMMIT_BRANCH == "develop"
    - if: $CI_COMMIT_BRANCH == "main"

# ==================== DESTROY ====================

destroy:development:
  extends: .terraform_base
  stage: destroy
  variables:
    ENVIRONMENT: development
  script:
    - terraform destroy -auto-approve
  rules:
    - if: $CI_COMMIT_BRANCH =~ /^feature\//
      when: manual
  environment:
    name: development
    action: stop

Pipeline GitHub Actions

# .github/workflows/infrastructure.yml

name: Infrastructure CI/CD

on:
  push:
    branches: [main, develop]
    paths:
      - 'terraform/**'
      - 'ansible/**'
  pull_request:
    branches: [main, develop]
    paths:
      - 'terraform/**'
      - 'ansible/**'

env:
  TF_VERSION: '1.6.6'
  OS_AUTH_URL: ${{ secrets.OS_AUTH_URL }}
  OS_USERNAME: ${{ secrets.OS_USERNAME }}
  OS_PASSWORD: ${{ secrets.OS_PASSWORD }}
  OS_PROJECT_NAME: ${{ secrets.OS_PROJECT_NAME }}
  OS_USER_DOMAIN_NAME: Default
  OS_PROJECT_DOMAIN_NAME: Default

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - name: Terraform Format Check
        run: terraform fmt -check -recursive terraform/

      - name: Terraform Validate
        run: |
          cd terraform/modules/network
          terraform init -backend=false
          terraform validate
          cd ../compute
          terraform init -backend=false
          terraform validate

      - name: Security Scan (tfsec)
        uses: aquasecurity/tfsec-action@v1.0.0
        with:
          working_directory: terraform/

      - name: Ansible Lint
        uses: ansible/ansible-lint@main
        with:
          path: ansible/playbooks/

  plan:
    needs: validate
    runs-on: ubuntu-latest
    strategy:
      matrix:
        environment: [staging, production]
        exclude:
          - environment: production
            ref: refs/heads/develop
    steps:
      - uses: actions/checkout@v4

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

      - name: Terraform Init
        run: |
          cd terraform/environments/${{ matrix.environment }}
          terraform init

      - name: Terraform Plan
        id: plan
        run: |
          cd terraform/environments/${{ matrix.environment }}
          terraform plan -out=tfplan -input=false

      - name: Upload Plan
        uses: actions/upload-artifact@v4
        with:
          name: tfplan-${{ matrix.environment }}
          path: terraform/environments/${{ matrix.environment }}/tfplan

      - name: Comment PR with Plan
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const output = `#### Terraform Plan - ${{ matrix.environment }}
            \`\`\`
            ${{ steps.plan.outputs.stdout }}
            \`\`\`
            `;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            })

  apply:
    needs: plan
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
    environment:
      name: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
    steps:
      - uses: actions/checkout@v4

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

      - name: Download Plan
        uses: actions/download-artifact@v4
        with:
          name: tfplan-${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
          path: terraform/environments/${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}

      - name: Terraform Apply
        run: |
          ENV=${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
          cd terraform/environments/$ENV
          terraform init
          terraform apply -input=false tfplan

      - name: Export Outputs
        id: outputs
        run: |
          ENV=${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
          cd terraform/environments/$ENV
          terraform output -json > outputs.json
          echo "outputs=$(cat outputs.json)" >> $GITHUB_OUTPUT

      - name: Upload Outputs
        uses: actions/upload-artifact@v4
        with:
          name: outputs-${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
          path: terraform/environments/${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}/outputs.json

  configure:
    needs: apply
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
    steps:
      - uses: actions/checkout@v4

      - name: Download Outputs
        uses: actions/download-artifact@v4
        with:
          name: outputs-${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
          path: ./

      - name: Setup SSH
        uses: webfactory/ssh-agent@v0.8.0
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

      - name: Generate Inventory
        run: |
          cat outputs.json | jq -r '.ansible_inventory.value | to_entries | .[] | "\(.key) ansible_host=\(.value.ansible_host)"' > ansible/inventory/hosts.ini

      - name: Run Ansible
        uses: dawidd6/action-ansible-playbook@v2
        with:
          playbook: ansible/playbooks/site.yml
          directory: ./
          options: |
            --inventory ansible/inventory/hosts.ini
            --extra-vars "environment=${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}"

Diagramme de pipeline

flowchart TD
    Start([Developer]) --> Push[Push code to feature branch]
    Push --> Trigger[Trigger on push]

    subgraph "Validate Stage"
        Trigger --> TFFmt[terraform fmt]
        Trigger --> TFVal[terraform validate]
        Trigger --> TFSec[tfsec security scan]
        Trigger --> AnsLint[ansible-lint]
    end

    TFFmt --> ValOK{Validation OK?}
    TFVal --> ValOK
    TFSec --> ValOK
    AnsLint --> ValOK

    ValOK -->|no| FixErr[Fix errors]
    FixErr --> End1([Stop])

    ValOK -->|yes| PlanStage

    subgraph "Plan Stage"
        PlanStage[terraform plan]
        PlanStage --> SavePlan[Save plan artifact]
    end

    SavePlan --> MR{Merge Request?}
    MR -->|yes| Comment[Comment PR with plan]
    Comment --> Review[Review plan]
    Review --> Approve[Approve MR]
    Approve --> Protected{Protected branch?}
    MR -->|no| Protected

    Protected -->|yes| ApplyStage

    subgraph "Apply Stage"
        ApplyStage[terraform apply<br/>manual approval]
    end

    ApplyStage --> ConfigStage

    subgraph "Configure Stage"
        ConfigStage[Generate Ansible inventory]
        ConfigStage --> AnsPlay[ansible-playbook site.yml]
    end

    AnsPlay --> TestStage

    subgraph "Test Stage"
        TestStage[Integration tests]
        TestStage --> Health[Health checks]
    end

    Health --> Complete[Deployment complete]
    Protected -->|no| Complete
    Complete --> End2([Stop])

Variables CI/CD requises

# Variables à configurer dans GitLab/GitHub

# OpenStack credentials
OS_AUTH_URL: "https://cloud.example.com:5000/v3"
OS_USERNAME: "ci-user"
OS_PASSWORD: "secret"  # Masked/Secret
OS_PROJECT_NAME: "infrastructure"
OS_USER_DOMAIN_NAME: "Default"
OS_PROJECT_DOMAIN_NAME: "Default"

# SSH pour Ansible
SSH_PRIVATE_KEY: |  # Masked/Secret
  -----BEGIN OPENSSH PRIVATE KEY-----
  ...
  -----END OPENSSH PRIVATE KEY-----

# Terraform backend (optionnel si utilisation du backend intégré)
TF_STATE_BUCKET: "terraform-state"

Bonnes pratiques

# Règles de protection des branches

# main (production)
protection_rules:
  require_pull_request_reviews: true
  required_approving_review_count: 2
  dismiss_stale_reviews: true
  require_status_checks:
    - validate:lint
    - validate:security
    - plan:production
  require_signed_commits: true
  enforce_admins: true

# develop (staging)
protection_rules:
  require_pull_request_reviews: true
  required_approving_review_count: 1
  require_status_checks:
    - validate:lint
    - plan:staging

Exemples pratiques

Exécution locale avant push

#!/bin/bash
# scripts/pre-commit.sh

set -e

echo "=== Pre-commit checks ==="

# Terraform format
echo "Checking Terraform format..."
terraform fmt -check -recursive terraform/

# Terraform validate
echo "Validating Terraform..."
for module in terraform/modules/*/; do
    cd "$module"
    terraform init -backend=false
    terraform validate
    cd -
done

# Security scan
echo "Running security scan..."
tfsec terraform/

# Ansible lint
echo "Linting Ansible..."
ansible-lint ansible/playbooks/

echo "=== All checks passed ==="

Déclenchement manuel

# GitLab - déclencher un pipeline manuellement
curl --request POST \
  --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
  --form "variables[ENVIRONMENT]=production" \
  "https://gitlab.example.com/api/v4/projects/123/pipeline"

# GitHub - workflow dispatch
gh workflow run infrastructure.yml \
  --field environment=production

Ressources

Checkpoint

  • Pipeline CI/CD configuré (GitLab ou GitHub)
  • Stages validate, plan, apply, configure fonctionnels
  • Variables CI/CD sécurisées
  • Approbation manuelle pour production
  • Tests d'intégration automatisés
  • Protection des branches configurée
  • Documentation du pipeline à jour