Projet 6 : Pipeline CI/CD Terraform
Contexte
Jusqu'à présent, vous avez exécuté Terraform manuellement. Il est temps d'automatiser ! Ce projet met en place un pipeline CI/CD complet pour les déploiements Terraform.
Exigences
- Pull Request avec plan automatique
- Approbation manuelle pour production
- State sécurisé sur S3
- Secrets gérés via AWS Secrets Manager
- Notifications Slack
Architecture CI/CD
Structure du projet
infra-cicd/
├── .github/
│ ├── workflows/
│ │ ├── terraform-plan.yml
│ │ ├── terraform-apply.yml
│ │ └── terraform-destroy.yml
│ └── CODEOWNERS
├── modules/
│ └── ...
├── environments/
│ ├── dev/
│ ├── staging/
│ └── prod/
├── scripts/
│ ├── setup-backend.sh
│ └── notify-slack.sh
└── .pre-commit-config.yaml
Configuration du Backend
scripts/setup-backend.sh
#!/bin/bash
set -e
PROJECT_NAME="myapp"
REGION="eu-west-1"
# Créer le bucket S3
aws s3api create-bucket \
--bucket ${PROJECT_NAME}-terraform-state \
--region ${REGION} \
--create-bucket-configuration LocationConstraint=${REGION}
# Activer le versioning
aws s3api put-bucket-versioning \
--bucket ${PROJECT_NAME}-terraform-state \
--versioning-configuration Status=Enabled
# Activer le chiffrement
aws s3api put-bucket-encryption \
--bucket ${PROJECT_NAME}-terraform-state \
--server-side-encryption-configuration '{
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms"
}
}
]
}'
# Bloquer l'accès public
aws s3api put-public-access-block \
--bucket ${PROJECT_NAME}-terraform-state \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
# Créer la table DynamoDB pour les locks
aws dynamodb create-table \
--table-name terraform-locks \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--region ${REGION}
echo "Backend configured successfully!"
GitHub Actions Workflows
.github/workflows/terraform-plan.yml
name: Terraform Plan
on:
pull_request:
branches:
- main
paths:
- 'environments/**'
- 'modules/**'
- '.github/workflows/terraform-*.yml'
env:
TF_VERSION: "1.6.0"
AWS_REGION: "eu-west-1"
permissions:
id-token: write
contents: read
pull-requests: write
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
environments: ${{ steps.filter.outputs.changes }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
dev:
- 'environments/dev/**'
- 'modules/**'
staging:
- 'environments/staging/**'
- 'modules/**'
prod:
- 'environments/prod/**'
- 'modules/**'
lint:
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
- name: TFLint
uses: reviewdog/action-tflint@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: github-pr-review
fail_on_error: true
- name: Checkov Security Scan
uses: bridgecrewio/checkov-action@v12
with:
directory: .
framework: terraform
output_format: github_failed_only
soft_fail: true
plan:
needs: [detect-changes, lint]
runs-on: ubuntu-latest
strategy:
matrix:
environment: ${{ fromJson(needs.detect-changes.outputs.environments) }}
fail-fast: false
steps:
- uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-terraform
aws-region: ${{ env.AWS_REGION }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init
working-directory: environments/${{ matrix.environment }}
run: terraform init -input=false
- name: Terraform Validate
working-directory: environments/${{ matrix.environment }}
run: terraform validate
- name: Terraform Plan
id: plan
working-directory: environments/${{ matrix.environment }}
run: |
terraform plan -input=false -no-color -out=tfplan 2>&1 | tee plan.txt
echo "plan<<EOF" >> $GITHUB_OUTPUT
cat plan.txt >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
continue-on-error: true
- name: Save Plan Artifact
uses: actions/upload-artifact@v4
with:
name: tfplan-${{ matrix.environment }}
path: environments/${{ matrix.environment }}/tfplan
retention-days: 7
- name: Comment PR with Plan
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const environment = '${{ matrix.environment }}';
const plan = `${{ steps.plan.outputs.plan }}`;
const planStatus = '${{ steps.plan.outcome }}';
const output = `## Terraform Plan - ${environment.toUpperCase()} 🏗️
**Status**: ${planStatus === 'success' ? '✅ Success' : '❌ Failed'}
<details>
<summary>Show Plan</summary>
\`\`\`terraform
${plan.substring(0, 65000)}
\`\`\`
</details>
*Pusher: @${{ 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: Plan Status
if: steps.plan.outcome == 'failure'
run: exit 1
cost-estimation:
needs: [detect-changes]
runs-on: ubuntu-latest
if: needs.detect-changes.outputs.environments != '[]'
steps:
- uses: actions/checkout@v4
- name: Infracost
uses: infracost/actions/setup@v3
with:
api-key: ${{ secrets.INFRACOST_API_KEY }}
- name: Generate Cost Estimate
run: |
infracost breakdown --path=. \
--format=json \
--out-file=/tmp/infracost.json
- name: Post Cost Comment
run: |
infracost comment github \
--path=/tmp/infracost.json \
--repo=${{ github.repository }} \
--pull-request=${{ github.event.pull_request.number }} \
--github-token=${{ secrets.GITHUB_TOKEN }} \
--behavior=update
.github/workflows/terraform-apply.yml
name: Terraform Apply
on:
push:
branches:
- main
paths:
- 'environments/**'
- 'modules/**'
workflow_dispatch:
inputs:
environment:
description: 'Environment to deploy'
required: true
type: choice
options:
- dev
- staging
- prod
env:
TF_VERSION: "1.6.0"
AWS_REGION: "eu-west-1"
permissions:
id-token: write
contents: read
jobs:
deploy-dev:
runs-on: ubuntu-latest
environment: dev
outputs:
apply_status: ${{ steps.apply.outcome }}
steps:
- uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-terraform
aws-region: ${{ env.AWS_REGION }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init
working-directory: environments/dev
run: terraform init -input=false
- name: Terraform Apply
id: apply
working-directory: environments/dev
run: terraform apply -input=false -auto-approve
- name: Notify Slack
if: always()
uses: slackapi/slack-github-[email protected]
with:
payload: |
{
"text": "Terraform Apply DEV: ${{ steps.apply.outcome }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Terraform Apply - DEV*\n*Status*: ${{ steps.apply.outcome == 'success' && '✅ Success' || '❌ Failed' }}\n*Commit*: ${{ github.sha }}\n*Actor*: ${{ github.actor }}"
}
}
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
deploy-staging:
needs: deploy-dev
runs-on: ubuntu-latest
environment: staging
if: needs.deploy-dev.outputs.apply_status == 'success'
outputs:
apply_status: ${{ steps.apply.outcome }}
steps:
- uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-terraform
aws-region: ${{ env.AWS_REGION }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init
working-directory: environments/staging
run: terraform init -input=false
- name: Terraform Apply
id: apply
working-directory: environments/staging
run: terraform apply -input=false -auto-approve
# Tests post-déploiement
- name: Run Integration Tests
run: |
cd tests
./run-integration-tests.sh staging
deploy-prod:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production # Requiert approbation manuelle
if: needs.deploy-staging.outputs.apply_status == 'success'
steps:
- uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID_PROD }}:role/github-actions-terraform
aws-region: ${{ env.AWS_REGION }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init
working-directory: environments/prod
run: terraform init -input=false
- name: Terraform Apply
id: apply
working-directory: environments/prod
run: terraform apply -input=false -auto-approve
- name: Notify Slack (Production)
if: always()
uses: slackapi/slack-github-[email protected]
with:
payload: |
{
"text": "🚀 PRODUCTION Deploy: ${{ steps.apply.outcome }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*🚀 Terraform Apply - PRODUCTION*\n*Status*: ${{ steps.apply.outcome == 'success' && '✅ Success' || '❌ Failed' }}\n*Commit*: ${{ github.sha }}\n*Actor*: ${{ github.actor }}"
}
}
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
Configuration AWS IAM pour OIDC
iam-oidc-role.tf
# OIDC Provider pour GitHub Actions
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
# IAM Role pour GitHub Actions
resource "aws_iam_role" "github_actions_terraform" {
name = "github-actions-terraform"
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 = {
"token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/${var.github_repo}:*"
}
}
}
]
})
}
# Politique Terraform
resource "aws_iam_role_policy" "terraform" {
name = "terraform-permissions"
role = aws_iam_role.github_actions_terraform.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket"
]
Resource = [
"arn:aws:s3:::${var.state_bucket}",
"arn:aws:s3:::${var.state_bucket}/*"
]
},
{
Effect = "Allow"
Action = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem"
]
Resource = "arn:aws:dynamodb:*:*:table/terraform-locks"
},
{
Effect = "Allow"
Action = [
"ec2:*",
"rds:*",
"elasticache:*",
"iam:*",
"eks:*",
"s3:*",
"cloudwatch:*",
"logs:*",
"kms:*"
]
Resource = "*"
Condition = {
StringEquals = {
"aws:RequestedRegion" = var.region
}
}
}
]
})
}
Pre-commit Hooks
.pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: detect-private-key
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.83.6
hooks:
- id: terraform_fmt
- id: terraform_validate
- id: terraform_docs
args:
- --args=--config=.terraform-docs.yml
- id: terraform_tflint
args:
- --args=--config=__GIT_WORKING_DIR__/.tflint.hcl
- id: terraform_checkov
args:
- --args=--quiet
- --args=--compact
- repo: https://github.com/gruntwork-io/pre-commit
rev: v0.1.23
hooks:
- id: tflint
- id: terraform-validate
GitHub Environments
Configuration via Terraform
# Utiliser le provider GitHub
terraform {
required_providers {
github = {
source = "integrations/github"
version = "~> 5.0"
}
}
}
provider "github" {
owner = var.github_org
}
# Environment Dev
resource "github_repository_environment" "dev" {
environment = "dev"
repository = var.github_repo
}
# Environment Staging
resource "github_repository_environment" "staging" {
environment = "staging"
repository = var.github_repo
reviewers {
teams = [data.github_team.devops.id]
}
deployment_branch_policy {
protected_branches = true
custom_branch_policies = false
}
}
# Environment Production (avec approbation)
resource "github_repository_environment" "production" {
environment = "production"
repository = var.github_repo
reviewers {
users = [data.github_user.lead.id]
teams = [data.github_team.leads.id]
}
deployment_branch_policy {
protected_branches = true
custom_branch_policies = false
}
}
# Secrets par environnement
resource "github_actions_environment_secret" "aws_account_id" {
for_each = toset(["dev", "staging", "production"])
repository = var.github_repo
environment = each.key
secret_name = "AWS_ACCOUNT_ID"
plaintext_value = var.aws_accounts[each.key]
}
Workflow de travail
Résumé
Ce pipeline CI/CD offre :
- Automatisation : Déploiement continu sans intervention
- Sécurité : Approbation pour production
- Visibilité : Plans dans les PRs
- Traçabilité : Historique complet dans Git