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¶
- Terraform Provider
- Ansible Post-provisioning
- Git et GitLab/GitHub
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