================================================================================
  ████████╗███████╗██████╗ ██████╗  █████╗ ███████╗ ██████╗ ██████╗ ███╗   ███╗
     ██╔══╝██╔════╝██╔══██╗██╔══██╗██╔══██╗██╔════╝██╔═══██╗██╔══██╗████╗ ████║
     ██║   █████╗  ██████╔╝██████╔╝███████║█████╗  ██║   ██║██████╔╝██╔████╔██║
     ██║   ██╔══╝  ██╔══██╗██╔══██╗██╔══██║██╔══╝  ██║   ██║██╔══██╗██║╚██╔╝██║
     ██║   ███████╗██║  ██║██║  ██║██║  ██║██║     ╚██████╔╝██║  ██║██║ ╚═╝ ██║
     ╚═╝   ╚══════╝╚═╝  ╚═╝╚═╝  ╚═╝╚═╝  ╚═╝╚═╝      ╚═════╝ ╚═╝  ╚═╝╚═╝     ╚═╝
          ██╗  ██╗    █████╗ ███╗  ██╗███████╗██╗██████╗ ██╗     ███████╗
          ╚██╗██╔╝   ██╔══██╗████╗ ██║██╔════╝██║██╔══██╗██║     ██╔════╝
           ╚███╔╝    ███████║██╔██╗██║███████╗██║██████╔╝██║     █████╗
           ██╔██╗    ██╔══██║██║╚████║╚════██║██║██╔══██╗██║     ██╔══╝
          ██╔╝╚██╗   ██║  ██║██║ ╚███║███████║██║██████╔╝███████╗███████╗
          ╚═╝  ╚═╝   ╚═╝  ╚═╝╚═╝  ╚══╝╚══════╝╚═╝╚═════╝ ╚══════╝╚══════╝
================================================================================
  TERRAFORM & ANSIBLE EN ÉQUIPE DE 3 DÉVELOPPEURS — APPLICATION FLASK
  Guide ultra-détaillé pour GRAND DÉBUTANT
  Chaque action expliquée: POURQUOI? COMMENT? QUAND? QUI?
  Toutes les fonctionnalités explorées sans exception
================================================================================

[BLACK_RIGHT-POINTING_TRIANGLE] À QUI S'ADRESSE CE GUIDE?
  Ce guide est pour quelqu'un qui:
  - n'a jamais entendu parler de Terraform ou Ansible (ou très peu)
  - se demande pourquoi ces outils existent alors qu'on a déjà Docker
  - veut comprendre AVANT de taper des commandes
  - travaille en équipe sur une API Flask Python
  - veut un déploiement professionnel, reproductible et automatisé

[BLACK_RIGHT-POINTING_TRIANGLE] COMMENT LIRE CE GUIDE?
  Chaque section suit ce format:
  ┌─ POURQUOI? ──── Pourquoi cet outil / cette commande existe-t-il?
  ├─ QU'EST-CE?  ── Explication claire du concept en français simple
  ├─ QUAND? ─────── Dans quelle situation l'utiliser?
  ├─ COMMENT? ───── Actions concrètes, une par une, avec sorties attendues
  └─ RÉSULTAT ───── Ce que vous devez voir à l'écran

[BLACK_RIGHT-POINTING_TRIANGLE] L'ÉQUIPE (même équipe que le guide Git & GitHub)
  Alice  — Lead dev + DevOps. Responsable Terraform, Ansible, déploiement.
           Machine: MacBook Pro macOS. Rôle IaC: écrit tout, forme Bob et Claire.

  Bob    — Développeur backend Flask.
           Machine: PC Ubuntu 22.04. Rôle IaC: utilise les environnements créés par Alice.

  Claire — Développeuse + tests.
           Machine: PC Windows 11. Rôle IaC: valide les déploiements, écrit les tests Ansible.

[BLACK_RIGHT-POINTING_TRIANGLE] L'APPLICATION (même app que le guide Git & GitHub)
  TaskManager API — Flask + PostgreSQL + Redis + Nginx
  Déployée sur: serveurs Ubuntu 22.04 (staging + production)
  Cloud simulé:   DigitalOcean (ou AWS/GCP selon variante)

[BLACK_RIGHT-POINTING_TRIANGLE] INFRASTRUCTURE CIBLE
  Avec Terraform et Ansible, l'équipe va construire:

  INTERNET
     │
  [Nginx LB]  <- Nginx reverse proxy sur port 80/443
     │
  [Flask API]  <- Application Flask (Gunicorn, 3 workers)
     │         <- Variables d'environnement injectées par Ansible
  [PostgreSQL] <- Base de données persistante
  [Redis]      <- Cache et sessions
     │
  [Monitoring] <- Prometheus + Grafana (optionnel)

================================================================================
PARTIE 0 — COMPRENDRE TERRAFORM ET ANSIBLE AVANT DE TOUCHER UN TERMINAL
================================================================================

────────────────────────────────────────────────────────────────────────────────
0.1 — LE PROBLÈME SANS CES OUTILS
────────────────────────────────────────────────────────────────────────────────

  SITUATION RÉELLE DE L'ÉQUIPE (Jour 0, sans Terraform ni Ansible):
  ────────────────────────────────────────────────────────────────────

  Alice doit déployer TaskManager API sur un serveur de staging et un serveur
  de production. Elle fait tout à la main:

  ── Ce qu'Alice fait sur le serveur staging ──────────────────────────────────
  1. Se connecter en SSH: ssh ubuntu@staging-ip
  2. Installer Python: sudo apt install python3 python3-pip
  3. Installer PostgreSQL: sudo apt install postgresql
  4. Créer la base: sudo -u postgres createdb taskmanager
  5. Configurer le mot de passe: ALTER USER postgres PASSWORD '...'
  6. Installer Redis: sudo apt install redis-server
  7. Cloner le code: git clone ...
  8. Créer le venv: python3 -m venv venv
  9. Installer les deps: pip install -r requirements.txt
  10. Créer le .env: nano /opt/taskmanager/.env
  11. Configurer Nginx: nano /etc/nginx/sites-available/taskmanager
  12. Activer le site: sudo ln -s ...
  13. Créer le service systemd: nano /etc/systemd/system/taskmanager.service
  14. Démarrer: sudo systemctl start taskmanager
  15. Vérifier: curl http://localhost/health

  PROBLÈME 1: Elle doit répéter EXACTEMENT les 15 étapes sur le serveur prod.
  -> Elle oublie l'étape 9 sur prod -> l'API plante -> "ça marchait en staging!"

  PROBLÈME 2: Bob doit créer un serveur de test pour sa feature.
  -> Il demande à Alice -> Alice est occupée -> Bob attend 2 jours.

  PROBLÈME 3: Le serveur de staging tombe.
  -> Alice doit refaire les 15 étapes -> 2 heures de travail répété.

  PROBLÈME 4: "Quelle version de Nginx avons-nous en prod?"
  -> Personne ne sait. La config n'est nulle part documentée.

  PROBLÈME 5: Le client demande un 2ème serveur de prod dans une autre région.
  -> Alice refait les 15 étapes -> encore 2 heures -> encore des oublis possibles.

  ─────────────────────────────────────────────────────────────────────────────
  AVEC TERRAFORM ET ANSIBLE (après ce guide):
  ─────────────────────────────────────────────
  Alice écrit du code (une seule fois):
  terraform apply   -> Crée les serveurs en 3 minutes (automatiquement)
  ansible-playbook  -> Configure tout en 5 minutes (automatiquement)

  Bob veut un serveur de test?
  -> Il copie le dossier terraform/environments/staging -> change 2 variables
  -> terraform apply -> serveur prêt en 3 minutes. Sans Alice.

  Serveur qui tombe?
  -> terraform apply -> serveur identique recréé en 3 minutes.
  -> ansible-playbook -> configuré identiquement en 5 minutes.

  2ème serveur de prod dans une autre région?
  -> Changer 1 variable (region = "ams3") -> terraform apply. 3 minutes.

────────────────────────────────────────────────────────────────────────────────
0.2 — TERRAFORM: L'ARCHITECTE (CRÉER L'INFRASTRUCTURE)
────────────────────────────────────────────────────────────────────────────────

  QU'EST-CE QUE TERRAFORM?
  ─────────────────────────
  Terraform est un outil "Infrastructure as Code" (IaC).
  Il crée et gère des ressources cloud (serveurs, réseaux, DNS, bases de données)
  en lisant des fichiers de configuration (.tf).

  ANALOGIE:
  Un architecte dessine un plan de maison. Les ouvriers construisent d'après le plan.
  Terraform = le plan. Le cloud (DigitalOcean, AWS...) = les ouvriers.
  Si vous détruisez la maison et redonnez le même plan -> maison identique reconstruite.

  CE QUE TERRAFORM PEUT CRÉER:
  ──────────────────────────────
  -> Des serveurs (Droplets DigitalOcean, EC2 AWS, VMs GCP...)
  -> Des réseaux privés (VPC, sous-réseaux)
  -> Des règles de pare-feu (firewalls, security groups)
  -> Des noms de domaine et enregistrements DNS
  -> Des bases de données managées (RDS, Cloud SQL...)
  -> Des buckets de stockage (S3, GCS...)
  -> Des clés SSH
  -> Des load balancers
  -> Des certificats SSL
  -> Pratiquement tout ce qu'un cloud propose

  CE QUE TERRAFORM NE FAIT PAS:
  ────────────────────────────────
  Terraform crée les serveurs, mais ne configure pas ce qui tourne dessus.
  Il crée un serveur Ubuntu vide. Il n'installe pas Python, Flask, Nginx.
  C'est le rôle d'Ansible.

  CONCEPTS CLÉS DE TERRAFORM:
  ─────────────────────────────
  Provider    -> Plugin qui sait parler à un cloud spécifique.
               Provider "digitalocean" = Terraform peut créer des Droplets.
               Provider "aws" = Terraform peut créer des EC2.

  Resource    -> Une ressource cloud à créer/gérer.
               resource "digitalocean_droplet" "api" { ... }
               = "Crée un serveur DigitalOcean appelé api avec ces specs"

  Variable    -> Paramètre configurable (region, taille du serveur, nom...).
               variable "region" { default = "fra1" }

  Output      -> Valeur exportée après création (IP du serveur, ID...).
               output "api_ip" { value = digitalocean_droplet.api.ipv4_address }

  State       -> Fichier terraform.tfstate: la mémoire de Terraform.
               Terraform sait ce qu'il a déjà créé grâce à ce fichier.
               Si vous supprimez terraform.tfstate: Terraform "oublie" tout.

  Plan        -> Aperçu de ce que Terraform VA faire (sans rien créer).
               terraform plan = "voici ce que je vais créer/modifier/détruire"

  Apply       -> Exécution du plan (création réelle des ressources).
               terraform apply = "créer maintenant"

  Destroy     -> Suppression de toutes les ressources gérées.
               terraform destroy = "supprimer tout ce que j'ai créé"

  Module      -> Groupe de fichiers .tf réutilisables (comme une fonction).
               module "server" { source = "./modules/server" }

  Backend     -> Où stocker le state (local, S3, Terraform Cloud...).
               En équipe: le state doit être PARTAGÉ et VERROUILLÉ.

────────────────────────────────────────────────────────────────────────────────
0.3 — ANSIBLE: LE CHEF D'ORCHESTRE (CONFIGURER LES SERVEURS)
────────────────────────────────────────────────────────────────────────────────

  QU'EST-CE QU'ANSIBLE?
  ──────────────────────
  Ansible est un outil d'automatisation qui configure des serveurs via SSH.
  Il lit des fichiers YAML appelés "playbooks" et exécute des tâches.

  ANALOGIE:
  Terraform a créé la maison (murs, toit, électricité brute).
  Ansible = les décorateurs d'intérieur. Ils installent les meubles (Python,
  Flask, Nginx), branchent les appareils (services systemd), peignent les murs
  (fichiers de config), et s'assurent que tout est dans le bon ordre.

  POINTS FORTS D'ANSIBLE:
  ────────────────────────
  -> Agentless: aucun logiciel à installer sur les serveurs cibles.
    Il utilise SSH (déjà disponible sur tout serveur Ubuntu).
  -> Idempotent: exécuter le même playbook 10 fois = même résultat.
    "Installe Python" -> si Python est déjà installé: ne fait rien.
  -> Déclaratif: vous décrivez l'ÉTAT FINAL voulu, pas les étapes.
    "Le service flask doit être démarré" -> Ansible vérifie et démarre si nécessaire.

  CONCEPTS CLÉS D'ANSIBLE:
  ─────────────────────────
  Inventory   -> Liste des serveurs à gérer (IP, groupes, variables).
               [web]
               192.168.1.10  ansible_user=ubuntu

  Playbook    -> Fichier YAML décrivant des tâches à exécuter.
               - name: Installer Flask
                 pip: name=flask

  Task        -> Une action unitaire dans un playbook.
               Installer un paquet, copier un fichier, démarrer un service...

  Role        -> Ensemble de tasks, handlers, templates organisés.
               Comme une bibliothèque réutilisable de configuration.
               role "flask_app" = installe Python + Flask + crée le service

  Handler     -> Tâche déclenchée seulement si une autre tâche a changé quelque chose.
               "Redémarre Nginx seulement si nginx.conf a changé"

  Template    -> Fichier de configuration avec des variables (Jinja2).
               /etc/nginx/sites-available/taskmanager.j2
               -> server_name {{ domain_name }}; <- variable remplacée au déploiement

  Variable    -> Valeur configurable dans les playbooks.
               db_password: "{{ vault_db_password }}"

  Vault       -> Chiffrement des secrets dans les fichiers Ansible.
               ansible-vault encrypt vars/secrets.yml
               -> les mots de passe sont chiffrés dans Git.

  Module      -> Plugin Ansible pour une action spécifique.
               apt, pip, copy, template, service, git, docker_container...
               Ansible a 3000+ modules intégrés.

  Galaxy      -> Dépôt de roles Ansible communautaires (comme PyPI pour Python).
               ansible-galaxy install geerlingguy.nginx

────────────────────────────────────────────────────────────────────────────────
0.4 — TERRAFORM vs ANSIBLE: QUI FAIT QUOI?
────────────────────────────────────────────────────────────────────────────────

  ┌─────────────────────────────────────────────────────────────────────────┐
  │                    TERRAFORM                ANSIBLE                     │
  │  ─────────────────────────────────────────────────────────────────────  │
  │  Langage          HCL (propre à Terraform)  YAML                        │
  │  Rôle             Créer l'infra             Configurer les serveurs     │
  │  Agit sur         Cloud APIs                SSH vers les serveurs       │
  │  Quand?           Avant qu'un serveur existe Après qu'il existe         │
  │  Idempotent?      Oui (via state)            Oui (natif)                │
  │  State?           Oui (terraform.tfstate)    Non (vérifie à chaque fois)│
  │  Exemple          Créer un VPS Ubuntu        Installer Nginx sur ce VPS │
  └─────────────────────────────────────────────────────────────────────────┘

  LE FLUX COMPLET:

  Alice tape: terraform apply
      v
  Terraform lit main.tf -> appelle l'API DigitalOcean
      v
  DigitalOcean crée 2 serveurs Ubuntu (staging + prod)
      v
  Terraform retourne les IPs: staging=164.90.x.x, prod=164.90.y.y
      v
  Alice tape: ansible-playbook site.yml
      v
  Ansible se connecte en SSH à chaque serveur
      v
  Ansible installe Python, Flask, PostgreSQL, Redis, Nginx
  Ansible copie le code de l'application
  Ansible crée les fichiers .env avec les bons mots de passe
  Ansible démarre tous les services
      v
  L'API est disponible sur https://api.taskmanager.equipe.com

================================================================================
PARTIE 1 — ALICE INSTALLE ET CONFIGURE TERRAFORM ET ANSIBLE
================================================================================

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 1.1 — INSTALLER TERRAFORM (Machine d'Alice: macOS)
────────────────────────────────────────────────────────────────────────────────

  POURQUOI INSTALLER TERRAFORM EN LOCAL?
  ────────────────────────────────────────
  Terraform s'exécute depuis votre machine (ou depuis CI/CD).
  Il communique avec les APIs cloud via Internet.
  Vous n'avez pas besoin d'accès au serveur pour utiliser Terraform.

  INSTALLATION SUR MAC (Alice):
  ──────────────────────────────
  # Via Homebrew (recommandé — gère les mises à jour automatiquement):
  brew tap hashicorp/tap
  brew install hashicorp/tap/terraform

  VÉRIFICATION:
  terraform --version

  CE QUE VOUS VOYEZ:
  ───────────────────
  Terraform v1.7.0
  on darwin_amd64

  INSTALLATION SUR UBUNTU (Bob — pour les tests):
  ─────────────────────────────────────────────────
  sudo apt-get update && sudo apt-get install -y gnupg software-properties-common
  wget -O- https://apt.releases.hashicorp.com/gpg | \
    sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
  echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \
    https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \
    sudo tee /etc/apt/sources.list.d/hashicorp.list
  sudo apt-get update && sudo apt-get install terraform

  INSTALLATION SUR WINDOWS (Claire — via winget):
  ─────────────────────────────────────────────────
  winget install Hashicorp.Terraform
  # Ou: télécharger le .zip depuis https://developer.hashicorp.com/terraform/downloads
  # Extraire terraform.exe -> ajouter au PATH

  ACTIVER L'AUTO-COMPLÉTION (Mac/Linux):
  ────────────────────────────────────────
  terraform -install-autocomplete
  # Ajoute la complétion dans ~/.zshrc ou ~/.bashrc
  source ~/.zshrc    # Recharger le shell

  # Maintenant: terraform <TAB> propose les sous-commandes
  # terraform p<TAB> -> terraform plan

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 1.2 — INSTALLER ANSIBLE (Machine d'Alice: macOS)
────────────────────────────────────────────────────────────────────────────────

  POURQUOI ANSIBLE SUR MAC ET PAS SUR LE SERVEUR?
  ──────────────────────────────────────────────────
  Ansible est "agentless": il n'a besoin d'être installé que sur la machine
  qui LANCE les playbooks (la machine d'Alice = "control node").
  Les serveurs cibles n'ont besoin que de SSH et Python (pré-installés sur Ubuntu).

  INSTALLATION SUR MAC:
  ──────────────────────
  # Ansible est un package Python — utiliser pip dans un venv dédié:
  python3 -m venv ~/ansible-venv
  source ~/ansible-venv/bin/activate
  pip install ansible ansible-lint

  # Ou via Homebrew (plus simple mais moins contrôlé):
  brew install ansible

  VÉRIFICATION:
  ansible --version

  CE QUE VOUS VOYEZ:
  ───────────────────
  ansible [core 2.16.0]
    config file = /etc/ansible/ansible.cfg
    configured module search path = ['/home/alice/.ansible/plugins/modules']
    python version = 3.11.8
    jinja version = 3.1.2

  INSTALLATION SUR UBUNTU (Bob):
  ────────────────────────────────
  sudo apt-add-repository --yes --update ppa:ansible/ansible
  sudo apt-get install ansible

  # Vérification:
  ansible --version

  NOTE: Ansible ne fonctionne PAS nativement sur Windows.
  Claire utilise WSL2 (Windows Subsystem for Linux):
  ────────────────────────────────────────────────────
  # Dans WSL2 (Ubuntu):
  sudo apt-add-repository ppa:ansible/ansible
  sudo apt-get install ansible
  # Ansible dans WSL2 peut accéder aux serveurs distants via SSH.

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 1.3 — INSTALLER LES OUTILS COMPLÉMENTAIRES
────────────────────────────────────────────────────────────────────────────────

  # terraform-docs: générer la documentation des modules Terraform
  brew install terraform-docs          # Mac
  # ou: go install github.com/terraform-docs/terraform-docs@latest

  # tflint: linter pour les fichiers .tf (erreurs de syntaxe, style)
  brew install tflint                  # Mac
  # ou: curl -s https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh | bash

  # tfsec: scanner de sécurité pour Terraform (détecte les configs risquées)
  brew install tfsec                   # Mac
  # ou: go install github.com/aquasecurity/tfsec/cmd/tfsec@latest

  # checkov: scanner de sécurité multi-outils (Terraform, Ansible, Docker...)
  pip install checkov

  # ansible-lint: linter pour les playbooks Ansible
  pip install ansible-lint

  # Direnv: charger automatiquement les variables d'environnement
  brew install direnv                  # Mac
  sudo apt-get install direnv         # Ubuntu

================================================================================
PARTIE 2 — STRUCTURE DU PROJET INFRASTRUCTURE (ALICE L'ORGANISE)
================================================================================

  POURQUOI ORGANISER LES FICHIERS D'INFRA SÉPARÉMENT DU CODE FLASK?
  ──────────────────────────────────────────────────────────────────
  Le code applicatif (Flask) et l'infrastructure (serveurs, config) ont
  des cycles de vie différents. Alice les sépare dans le même dépôt Git
  mais dans des dossiers dédiés.

  STRUCTURE COMPLÈTE DU DÉPÔT:
  ──────────────────────────────
  taskmanager/
  ├── app/                      <- Code Flask (inchangé)
  ├── tests/                    <- Tests pytest (inchangés)
  ├── docker/                   <- Dockerfiles
  ├── .github/workflows/        <- CI/CD GitHub Actions
  │
  ├── infrastructure/           <- NOUVEAU: toute l'infra as code
  │   ├── terraform/            <- Terraform: créer les serveurs
  │   │   ├── environments/     <- Configs par environnement
  │   │   │   ├── staging/      <- Infra staging
  │   │   │   │   ├── main.tf
  │   │   │   │   ├── variables.tf
  │   │   │   │   ├── outputs.tf
  │   │   │   │   └── terraform.tfvars
  │   │   │   └── production/   <- Infra production
  │   │   │       ├── main.tf
  │   │   │       ├── variables.tf
  │   │   │       ├── outputs.tf
  │   │   │       └── terraform.tfvars
  │   │   └── modules/          <- Modules réutilisables
  │   │       ├── server/       <- Module: créer un serveur
  │   │       │   ├── main.tf
  │   │       │   ├── variables.tf
  │   │       │   └── outputs.tf
  │   │       ├── network/      <- Module: VPC + firewall
  │   │       │   ├── main.tf
  │   │       │   ├── variables.tf
  │   │       │   └── outputs.tf
  │   │       └── database/     <- Module: DB managée (optionnel)
  │   │           ├── main.tf
  │   │           ├── variables.tf
  │   │           └── outputs.tf
  │   │
  │   └── ansible/              <- Ansible: configurer les serveurs
  │       ├── inventory/        <- Liste des serveurs
  │       │   ├── staging.ini
  │       │   ├── production.ini
  │       │   └── dynamic/      <- Inventaire dynamique (depuis Terraform)
  │       │       └── terraform_inventory.py
  │       ├── roles/            <- Roles réutilisables
  │       │   ├── common/       <- Config de base (timezone, utilisateurs...)
  │       │   ├── python/       <- Installer Python + venv
  │       │   ├── flask_app/    <- Déployer l'application Flask
  │       │   ├── postgresql/   <- Installer et configurer PostgreSQL
  │       │   ├── redis/        <- Installer et configurer Redis
  │       │   ├── nginx/        <- Installer et configurer Nginx
  │       │   └── monitoring/   <- Installer Prometheus + Grafana
  │       ├── group_vars/       <- Variables par groupe de serveurs
  │       │   ├── all.yml       <- Variables communes à tous
  │       │   ├── staging.yml   <- Variables spécifiques staging
  │       │   └── production.yml <- Variables spécifiques production
  │       ├── host_vars/        <- Variables par serveur spécifique
  │       │   └── taskmanager-prod.yml
  │       ├── vars/             <- Variables chiffrées (Ansible Vault)
  │       │   ├── staging_secrets.yml   <- Secrets chiffrés staging
  │       │   └── prod_secrets.yml      <- Secrets chiffrés production
  │       ├── templates/        <- Templates Jinja2
  │       │   ├── nginx.conf.j2
  │       │   ├── taskmanager.service.j2
  │       │   └── .env.j2
  │       ├── site.yml          <- Playbook principal (tout configurer)
  │       ├── deploy.yml        <- Playbook de déploiement (code seul)
  │       ├── rollback.yml      <- Playbook de rollback
  │       └── ansible.cfg       <- Configuration Ansible

  CRÉER CETTE STRUCTURE:
  ───────────────────────
  cd taskmanager/
  mkdir -p infrastructure/terraform/environments/{staging,production}
  mkdir -p infrastructure/terraform/modules/{server,network,database}
  mkdir -p infrastructure/ansible/inventory/dynamic
  mkdir -p infrastructure/ansible/roles/{common,python,flask_app,postgresql,redis,nginx,monitoring}
  mkdir -p infrastructure/ansible/{group_vars,host_vars,vars,templates}

================================================================================
PARTIE 3 — TERRAFORM: CRÉER L'INFRASTRUCTURE (ALICE)
================================================================================

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 3.1 — CONFIGURER LE PROVIDER DIGITALOCEAN
────────────────────────────────────────────────────────────────────────────────

  POURQUOI DIGITALOCEAN POUR CE GUIDE?
  ──────────────────────────────────────
  DigitalOcean est plus simple que AWS pour débuter (moins de concepts à gérer).
  Le provider Terraform DigitalOcean est bien documenté.
  Les concepts sont identiques pour AWS/GCP/Azure — seuls les noms changent.

  CRÉER UN TOKEN API DIGITALOCEAN:
  ──────────────────────────────────
  1. Se connecter sur cloud.digitalocean.com
  2. API -> Personal access tokens -> Generate New Token
  3. Name: "terraform-taskmanager"
  4. Expiration: No expiry (ou 90 jours)
  5. Scopes: Read + Write
  6. Cliquer "Generate Token"
  7. Copier le token (visible UNE SEULE FOIS) -> stocker en sécurité

  NE JAMAIS mettre ce token dans un fichier .tf ou committé dans Git!
  -> Utiliser une variable d'environnement:
  export TF_VAR_do_token="dop_v1_VOTRE_TOKEN_ICI"
  # Ou dans un fichier .env non commité:
  echo 'TF_VAR_do_token=dop_v1_...' >> ~/.env.local

  ALICE CRÉE LE FICHIER DE CONFIGURATION TERRAFORM PRINCIPAL:
  ─────────────────────────────────────────────────────────────
  nano infrastructure/terraform/environments/staging/main.tf

  Contenu complet et commenté:

  # ═══════════════════════════════════════════════════════════════════════
  # main.tf — Infrastructure TaskManager STAGING
  # ═══════════════════════════════════════════════════════════════════════

  # BLOC terraform: configuration globale de Terraform pour ce projet
  terraform {
    # Version minimale de Terraform requise pour ce code.
    # Évite que quelqu'un utilise une version trop ancienne avec des
    # comportements différents. Utiliser ~> = "1.7.x mais pas 2.0"
    required_version = "~> 1.7"

    # Providers requis et leurs versions.
    # Terraform télécharge ces plugins lors du "terraform init".
    required_providers {
      digitalocean = {
        source  = "digitalocean/digitalocean"
        version = "~> 2.0"   # Version 2.x, pas 3.x
      }
    }

    # BACKEND: où stocker le state Terraform.
    # En équipe, le state DOIT être partagé (pas sur la machine d'Alice seule).
    # Option 1: Terraform Cloud (gratuit pour petites équipes):
    backend "terraform" {
      organization = "equipe-taskmanager"
      workspaces {
        name = "taskmanager-staging"
      }
    }
    # Option 2: S3 (si vous avez AWS):
    # backend "s3" {
    #   bucket = "taskmanager-terraform-state"
    #   key    = "staging/terraform.tfstate"
    #   region = "eu-west-3"
    # }
    # Option 3: local (développement uniquement, à ne pas utiliser en équipe):
    # backend "local" {
    #   path = "terraform.tfstate"
    # }
  }

  # PROVIDER: configure le plugin DigitalOcean.
  # La variable do_token sera fournie via:
  # - Variable d'environnement: TF_VAR_do_token
  # - Fichier .tfvars (non commité)
  provider "digitalocean" {
    token = var.do_token
  }

  # ═══════════════════════════════════════════════════════════════════════
  # DONNÉES: récupérer des infos existantes depuis DigitalOcean
  # ═══════════════════════════════════════════════════════════════════════

  # data source = lire une ressource EXISTANTE (pas en créer une nouvelle).
  # Ici: récupérer l'ID de la clé SSH déjà enregistrée sur DigitalOcean.
  data "digitalocean_ssh_key" "alice" {
    name = "alice-macbook-2024"
    # Ce nom doit correspondre à la clé ajoutée sur cloud.digitalocean.com/account/security
  }

  data "digitalocean_ssh_key" "deploy" {
    name = "github-actions-deploy"
    # Clé SSH utilisée par GitHub Actions pour les déploiements automatiques
  }

  # ═══════════════════════════════════════════════════════════════════════
  # RÉSEAU: VPC (réseau privé virtuel)
  # ═══════════════════════════════════════════════════════════════════════

  # Un VPC isole les serveurs dans un réseau privé.
  # Les serveurs d'un même VPC peuvent communiquer entre eux via des IPs privées
  # sans passer par Internet. Plus sécurisé et plus rapide.
  resource "digitalocean_vpc" "taskmanager_staging" {
    name     = "taskmanager-staging-vpc"
    region   = var.region        # "fra1" = Frankfurt
    ip_range = "10.0.0.0/16"    # Plage d'IPs privées pour ce VPC
    # 10.0.0.0/16 = IPs de 10.0.0.0 à 10.0.255.255 = 65534 serveurs max
  }

  # ═══════════════════════════════════════════════════════════════════════
  # PARE-FEU: règles de sécurité réseau
  # ═══════════════════════════════════════════════════════════════════════

  resource "digitalocean_firewall" "taskmanager_staging" {
    name = "taskmanager-staging-fw"

    # Tags: ce firewall s'applique à tous les Droplets ayant ce tag.
    # Quand on crée un Droplet avec tags = ["taskmanager-staging"],
    # ce firewall lui est automatiquement appliqué.
    tags = ["taskmanager-staging"]

    # ── TRAFIC ENTRANT (inbound) ──────────────────────────────────────
    # Règle 1: Autoriser SSH depuis n'importe où.
    # ATTENTION: en production, restreindre à des IPs fixes (officeIp, VPN).
    inbound_rule {
      protocol         = "tcp"
      port_range       = "22"
      source_addresses = ["0.0.0.0/0", "::/0"]   # IPv4 + IPv6
    }

    # Règle 2: Autoriser HTTP (port 80) depuis n'importe où.
    inbound_rule {
      protocol         = "tcp"
      port_range       = "80"
      source_addresses = ["0.0.0.0/0", "::/0"]
    }

    # Règle 3: Autoriser HTTPS (port 443) depuis n'importe où.
    inbound_rule {
      protocol         = "tcp"
      port_range       = "443"
      source_addresses = ["0.0.0.0/0", "::/0"]
    }

    # Règle 4: Communication interne entre serveurs du VPC.
    # PostgreSQL (5432) et Redis (6379) accessibles SEULEMENT depuis le VPC.
    # Le VPC a la plage 10.0.0.0/16 -> seuls les serveurs dans ce VPC peuvent accéder.
    inbound_rule {
      protocol         = "tcp"
      port_range       = "5432"
      source_addresses = ["10.0.0.0/16"]   # UNIQUEMENT depuis le VPC
    }

    inbound_rule {
      protocol         = "tcp"
      port_range       = "6379"
      source_addresses = ["10.0.0.0/16"]   # UNIQUEMENT depuis le VPC
    }

    # ── TRAFIC SORTANT (outbound) ─────────────────────────────────────
    # Autoriser tout le trafic sortant (le serveur peut télécharger des paquets).
    outbound_rule {
      protocol              = "tcp"
      port_range            = "1-65535"
      destination_addresses = ["0.0.0.0/0", "::/0"]
    }

    outbound_rule {
      protocol              = "udp"
      port_range            = "1-65535"
      destination_addresses = ["0.0.0.0/0", "::/0"]
    }

    outbound_rule {
      protocol              = "icmp"
      destination_addresses = ["0.0.0.0/0", "::/0"]
    }
  }

  # ═══════════════════════════════════════════════════════════════════════
  # SERVEURS (DROPLETS): les machines virtuelles
  # ═══════════════════════════════════════════════════════════════════════

  # SERVEUR PRINCIPAL: Flask API + Nginx + PostgreSQL + Redis
  # (Pour staging: tout sur un seul serveur. En prod: serveurs séparés.)
  resource "digitalocean_droplet" "taskmanager_staging" {
    name   = "taskmanager-staging"
    region = var.region         # Variable: "fra1" (Frankfurt)
    size   = var.droplet_size   # Variable: "s-2vcpu-4gb" (2 CPU, 4GB RAM)
    image  = "ubuntu-22-04-x64" # Image: Ubuntu 22.04 LTS

    # Placer le serveur dans le VPC privé créé ci-dessus.
    # Communication interne via IPs privées 10.0.x.x.
    vpc_uuid = digitalocean_vpc.taskmanager_staging.id

    # Clés SSH autorisées à se connecter à ce serveur.
    # Références aux data sources définis plus haut.
    ssh_keys = [
      data.digitalocean_ssh_key.alice.id,
      data.digitalocean_ssh_key.deploy.id,
    ]

    # Tags: ce Droplet recevra le firewall "taskmanager-staging".
    tags = ["taskmanager-staging", "flask", "staging"]

    # User data: script bash exécuté UNE SEULE FOIS à la création du serveur.
    # Utile pour une configuration minimale avant Ansible.
    user_data = <<-EOF
      #!/bin/bash
      # Mise à jour de sécurité initiale
      apt-get update -y
      apt-get upgrade -y
      # Installer Python (requis par Ansible)
      apt-get install -y python3 python3-pip
      # Créer l'utilisateur applicatif
      useradd -m -s /bin/bash deploy
      # Logs de l'user_data visibles dans: /var/log/cloud-init-output.log
    EOF

    # Activer la surveillance DigitalOcean (CPU, RAM, disque via API).
    monitoring = true

    # Activer les sauvegardes automatiques hebdomadaires.
    backups = false  # true en production (coût supplémentaire)

    # IPv6 activé (bonne pratique).
    ipv6 = true

    # Remarque: Terraform attend que le Droplet soit "active" avant de continuer.
  }

  # ═══════════════════════════════════════════════════════════════════════
  # DNS: enregistrements pour l'accès par nom de domaine
  # ═══════════════════════════════════════════════════════════════════════

  # Ajouter un enregistrement DNS A pointant vers le serveur staging.
  # Prérequis: le domaine "equipe.com" doit être géré par DigitalOcean DNS.
  resource "digitalocean_record" "staging_api" {
    domain = "equipe.com"
    type   = "A"
    name   = "api-staging"           # -> api-staging.equipe.com
    value  = digitalocean_droplet.taskmanager_staging.ipv4_address
    ttl    = 300                     # 5 minutes (bas pour staging = changements fréquents)
  }

  # ═══════════════════════════════════════════════════════════════════════
  # VOLUMES: stockage persistant (données qui survivent au redémarrage)
  # ═══════════════════════════════════════════════════════════════════════

  resource "digitalocean_volume" "staging_data" {
    region      = var.region
    name        = "taskmanager-staging-data"
    size        = 20               # 20 GB
    description = "Données PostgreSQL staging"
    # initial_filesystem_type = "ext4"  # Formaté automatiquement
  }

  # Attacher le volume au serveur:
  resource "digitalocean_volume_attachment" "staging_data" {
    droplet_id = digitalocean_droplet.taskmanager_staging.id
    volume_id  = digitalocean_volume.staging_data.id
    # Disponible comme /dev/sda sur le serveur (monté par Ansible ensuite)
  }

  # ═══════════════════════════════════════════════════════════════════════
  # ALERTES: notification si le serveur est en difficulté
  # ═══════════════════════════════════════════════════════════════════════

  resource "digitalocean_monitor_alert" "staging_cpu" {
    alerts {
      email = ["alice@equipe.com"]
    }
    window      = "5m"
    type        = "v1/insights/droplet/cpu"
    compare     = "GreaterThan"
    value       = 85               # Alerte si CPU > 85% pendant 5 min
    enabled     = true
    entities    = [digitalocean_droplet.taskmanager_staging.id]
    description = "CPU staging élevé"
  }

  resource "digitalocean_monitor_alert" "staging_memory" {
    alerts {
      email = ["alice@equipe.com"]
    }
    window      = "5m"
    type        = "v1/insights/droplet/memory_utilization_percent"
    compare     = "GreaterThan"
    value       = 90               # Alerte si RAM > 90% pendant 5 min
    enabled     = true
    entities    = [digitalocean_droplet.taskmanager_staging.id]
    description = "RAM staging élevée"
  }

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 3.2 — FICHIER VARIABLES.TF
────────────────────────────────────────────────────────────────────────────────

  nano infrastructure/terraform/environments/staging/variables.tf

  Contenu:
  # ═══════════════════════════════════════════════════════════════════════
  # variables.tf — Déclaration des variables pour l'environnement staging
  # ═══════════════════════════════════════════════════════════════════════

  # VARIABLE: Token API DigitalOcean
  # Fournie via: TF_VAR_do_token (variable d'environnement) — JAMAIS en dur
  variable "do_token" {
    description = "Token API DigitalOcean avec droits Read+Write"
    type        = string
    sensitive   = true   # sensitive = masque la valeur dans les logs Terraform
  }

  # VARIABLE: Région DigitalOcean
  variable "region" {
    description = "Région DigitalOcean pour le déploiement"
    type        = string
    default     = "fra1"   # Frankfurt — proche de la France

    # Validation: s'assurer que la région est valide
    validation {
      condition     = contains(["fra1", "ams3", "nyc1", "lon1", "sgp1"], var.region)
      error_message = "La région doit être: fra1, ams3, nyc1, lon1 ou sgp1."
    }
  }

  # VARIABLE: Taille du Droplet
  variable "droplet_size" {
    description = "Taille du serveur DigitalOcean (CPU + RAM)"
    type        = string
    default     = "s-2vcpu-4gb"   # Staging: 2 CPU, 4GB RAM (~24$/mois)
    # Production: "s-4vcpu-8gb" (4 CPU, 8GB RAM, ~48$/mois)
    # Voir: https://slugs.do-api.dev/ pour toutes les tailles disponibles
  }

  # VARIABLE: Environnement
  variable "environment" {
    description = "Environnement de déploiement"
    type        = string
    default     = "staging"

    validation {
      condition     = contains(["staging", "production"], var.environment)
      error_message = "L'environnement doit être: staging ou production."
    }
  }

  # VARIABLE: Nom du projet
  variable "project_name" {
    description = "Nom du projet (utilisé pour nommer les ressources)"
    type        = string
    default     = "taskmanager"
  }

  # VARIABLE: Tags communs (appliqués à toutes les ressources)
  variable "common_tags" {
    description = "Tags appliqués à toutes les ressources"
    type        = list(string)
    default     = ["taskmanager", "flask", "managed-by-terraform"]
  }

  # VARIABLE: Domaine
  variable "domain" {
    description = "Nom de domaine principal"
    type        = string
    default     = "equipe.com"
  }

  # VARIABLE: Activer les sauvegardes
  variable "enable_backups" {
    description = "Activer les sauvegardes automatiques DigitalOcean"
    type        = bool
    default     = false   # false en staging (économie), true en prod
  }

  # VARIABLE: Taille du volume de données
  variable "data_volume_size_gb" {
    description = "Taille du volume de données PostgreSQL en GB"
    type        = number
    default     = 20

    validation {
      condition     = var.data_volume_size_gb >= 10 && var.data_volume_size_gb <= 500
      error_message = "La taille du volume doit être entre 10 et 500 GB."
    }
  }

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 3.3 — FICHIER OUTPUTS.TF
────────────────────────────────────────────────────────────────────────────────

  nano infrastructure/terraform/environments/staging/outputs.tf

  Contenu:
  # ═══════════════════════════════════════════════════════════════════════
  # outputs.tf — Valeurs exportées après terraform apply
  # ═══════════════════════════════════════════════════════════════════════
  # Ces valeurs sont:
  # 1. Affichées à l'écran après terraform apply
  # 2. Utilisables par Ansible (terraform output -raw staging_ip)
  # 3. Utilisables dans d'autres modules Terraform

  output "staging_ip" {
    description = "Adresse IP publique du serveur staging"
    value       = digitalocean_droplet.taskmanager_staging.ipv4_address
  }

  output "staging_ip_private" {
    description = "Adresse IP privée (VPC) du serveur staging"
    value       = digitalocean_droplet.taskmanager_staging.ipv4_address_private
  }

  output "staging_id" {
    description = "ID unique du Droplet staging"
    value       = digitalocean_droplet.taskmanager_staging.id
  }

  output "staging_url" {
    description = "URL de l'API staging"
    value       = "https://api-staging.${var.domain}"
  }

  output "vpc_id" {
    description = "ID du VPC"
    value       = digitalocean_vpc.taskmanager_staging.id
  }

  output "volume_id" {
    description = "ID du volume de données"
    value       = digitalocean_volume.staging_data.id
  }

  output "firewall_id" {
    description = "ID du pare-feu"
    value       = digitalocean_firewall.taskmanager_staging.id
  }

  # Output SSH: commande prête à copier-coller pour se connecter
  output "ssh_command" {
    description = "Commande SSH pour se connecter au serveur staging"
    value       = "ssh ubuntu@${digitalocean_droplet.taskmanager_staging.ipv4_address}"
  }

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 3.4 — FICHIER TERRAFORM.TFVARS (VALEURS CONCRÈTES, NON COMMITÉ)
────────────────────────────────────────────────────────────────────────────────

  POURQUOI TERRAFORM.TFVARS?
  ────────────────────────────
  variables.tf DÉCLARE les variables (noms et types).
  terraform.tfvars DONNE les valeurs concrètes.
  Ce fichier est dans .gitignore car il peut contenir des valeurs sensibles.
  On commite terraform.tfvars.example (template sans valeurs réelles).

  # CRÉER le fichier de template (commité dans Git):
  nano infrastructure/terraform/environments/staging/terraform.tfvars.example

  Contenu:
  # Copier ce fichier: cp terraform.tfvars.example terraform.tfvars
  # Remplir les valeurs dans terraform.tfvars
  # NE PAS committer terraform.tfvars dans Git!

  do_token           = "VOTRE_TOKEN_DIGITALOCEAN_ICI"
  region             = "fra1"
  droplet_size       = "s-2vcpu-4gb"
  environment        = "staging"
  project_name       = "taskmanager"
  domain             = "equipe.com"
  enable_backups     = false
  data_volume_size_gb = 20

  # CRÉER le vrai fichier (non commité):
  cp infrastructure/terraform/environments/staging/terraform.tfvars.example \
     infrastructure/terraform/environments/staging/terraform.tfvars

  nano infrastructure/terraform/environments/staging/terraform.tfvars
  # Remplacer do_token par le vrai token.

  # AJOUTER terraform.tfvars dans .gitignore:
  echo "infrastructure/terraform/**/*.tfvars" >> .gitignore
  echo "infrastructure/terraform/**/.terraform/" >> .gitignore
  echo "infrastructure/terraform/**/terraform.tfstate" >> .gitignore
  echo "infrastructure/terraform/**/.terraform.lock.hcl" >> .gitignore

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 3.5 — MODULE TERRAFORM RÉUTILISABLE: SERVER
────────────────────────────────────────────────────────────────────────────────

  POURQUOI UN MODULE?
  ────────────────────
  staging/main.tf et production/main.tf ont presque le même code.
  Sans module: copier-coller -> 2 fichiers à maintenir -> divergence.
  Avec module: le code est écrit UNE FOIS, appelé avec des paramètres différents.
  Comme une fonction: module "staging" { ... } appelle le même code que module "prod" { ... }

  nano infrastructure/terraform/modules/server/main.tf

  Contenu:
  # ═══════════════════════════════════════════════════════════════════════
  # Module "server" — Créer un serveur Flask avec tout son environnement
  # ═══════════════════════════════════════════════════════════════════════
  # Usage:
  # module "staging_server" {
  #   source      = "../../modules/server"
  #   name        = "taskmanager-staging"
  #   region      = "fra1"
  #   size        = "s-2vcpu-4gb"
  #   environment = "staging"
  #   ssh_key_ids = [...]
  # }

  terraform {
    required_providers {
      digitalocean = {
        source  = "digitalocean/digitalocean"
        version = "~> 2.0"
      }
    }
  }

  # VPC pour ce serveur
  resource "digitalocean_vpc" "this" {
    name     = "${var.name}-vpc"
    region   = var.region
    ip_range = var.vpc_ip_range
  }

  # Pare-feu
  resource "digitalocean_firewall" "this" {
    name = "${var.name}-firewall"
    tags = [var.name]

    inbound_rule {
      protocol         = "tcp"
      port_range       = "22"
      source_addresses = var.ssh_allowed_ips
    }

    inbound_rule {
      protocol         = "tcp"
      port_range       = "80"
      source_addresses = ["0.0.0.0/0", "::/0"]
    }

    inbound_rule {
      protocol         = "tcp"
      port_range       = "443"
      source_addresses = ["0.0.0.0/0", "::/0"]
    }

    inbound_rule {
      protocol         = "tcp"
      port_range       = "5432"
      source_addresses = [var.vpc_ip_range]
    }

    inbound_rule {
      protocol         = "tcp"
      port_range       = "6379"
      source_addresses = [var.vpc_ip_range]
    }

    outbound_rule {
      protocol              = "tcp"
      port_range            = "1-65535"
      destination_addresses = ["0.0.0.0/0", "::/0"]
    }

    outbound_rule {
      protocol              = "udp"
      port_range            = "1-65535"
      destination_addresses = ["0.0.0.0/0", "::/0"]
    }
  }

  # Serveur
  resource "digitalocean_droplet" "this" {
    name       = var.name
    region     = var.region
    size       = var.size
    image      = "ubuntu-22-04-x64"
    vpc_uuid   = digitalocean_vpc.this.id
    ssh_keys   = var.ssh_key_ids
    tags       = concat([var.name, var.environment], var.additional_tags)
    monitoring = true
    backups    = var.enable_backups
    ipv6       = true

    user_data = <<-EOF
      #!/bin/bash
      apt-get update -y
      apt-get install -y python3 python3-pip
      useradd -m -s /bin/bash deploy
    EOF
  }

  # Volume de données (si demandé)
  resource "digitalocean_volume" "data" {
    count       = var.data_volume_size_gb > 0 ? 1 : 0
    region      = var.region
    name        = "${var.name}-data"
    size        = var.data_volume_size_gb
    description = "Données PostgreSQL pour ${var.name}"
  }

  resource "digitalocean_volume_attachment" "data" {
    count      = var.data_volume_size_gb > 0 ? 1 : 0
    droplet_id = digitalocean_droplet.this.id
    volume_id  = digitalocean_volume.data[0].id
  }

  nano infrastructure/terraform/modules/server/variables.tf
  # Variables du module:
  variable "name"              { type = string }
  variable "region"            { type = string; default = "fra1" }
  variable "size"              { type = string; default = "s-2vcpu-4gb" }
  variable "environment"       { type = string }
  variable "ssh_key_ids"       { type = list(string) }
  variable "vpc_ip_range"      { type = string; default = "10.0.0.0/16" }
  variable "ssh_allowed_ips"   { type = list(string); default = ["0.0.0.0/0", "::/0"] }
  variable "additional_tags"   { type = list(string); default = [] }
  variable "enable_backups"    { type = bool; default = false }
  variable "data_volume_size_gb" { type = number; default = 20 }

  nano infrastructure/terraform/modules/server/outputs.tf
  # Sorties du module:
  output "ip"         { value = digitalocean_droplet.this.ipv4_address }
  output "ip_private" { value = digitalocean_droplet.this.ipv4_address_private }
  output "id"         { value = digitalocean_droplet.this.id }
  output "vpc_id"     { value = digitalocean_vpc.this.id }

  UTILISER LE MODULE DANS STAGING (version simplifiée de main.tf):
  ─────────────────────────────────────────────────────────────────
  # Dans infrastructure/terraform/environments/staging/main.tf
  # remplacer tout le code par l'appel au module:

  module "staging_server" {
    source = "../../modules/server"

    name              = "taskmanager-staging"
    region            = var.region
    size              = var.droplet_size
    environment       = "staging"
    ssh_key_ids       = [data.digitalocean_ssh_key.alice.id, data.digitalocean_ssh_key.deploy.id]
    enable_backups    = false
    data_volume_size_gb = 20
  }

  # Puis dans outputs.tf utiliser module.staging_server.ip etc.

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 3.6 — ALICE EXÉCUTE TERRAFORM: INIT, PLAN, APPLY
────────────────────────────────────────────────────────────────────────────────

  ALICE DANS SON TERMINAL:
  ─────────────────────────
  cd infrastructure/terraform/environments/staging/

  ── COMMANDE 1: terraform init ───────────────────────────────────────────────
  POURQUOI? Initialise le répertoire de travail Terraform:
  -> Télécharge les plugins des providers (digitalocean ~2.0)
  -> Configure le backend (là où sera stocké le state)
  -> Installe les modules locaux
  QUAND? Une fois lors du premier setup, et à chaque ajout de provider/module.

  terraform init

  CE QUE VOUS VOYEZ:
  ───────────────────
  Initializing the backend...
  Initializing provider plugins...
  - Finding digitalocean/digitalocean versions matching "~> 2.0"...
  - Installing digitalocean/digitalocean v2.34.1...
  - Installed digitalocean/digitalocean v2.34.1 (signed by HashiCorp)

  Terraform has been successfully initialized!

  You may now begin working with Terraform. Try running "terraform plan" to see
  any changes that are required for your new infrastructure.

  # Un dossier .terraform/ est créé avec les plugins téléchargés.
  # Un fichier .terraform.lock.hcl est créé: version exacte des providers.
  # Ce fichier .lock.hcl DOIT être commité dans Git (reproductibilité).

  ── COMMANDE 2: terraform validate ──────────────────────────────────────────
  POURQUOI? Vérifie la syntaxe HCL sans contacter le cloud.
  QUAND? Avant chaque plan, en CI/CD.

  terraform validate

  CE QUE VOUS VOYEZ (tout est bon):
  ──────────────────────────────────
  Success! The configuration is valid.

  CE QUE VOUS VOYEZ (erreur de syntaxe):
  ────────────────────────────────────────
  Error: Invalid block definition
    on main.tf line 42, in resource "digitalocean_droplet" "taskmanager_staging":
    42: regoin = "fra1"
  A block definition must have block content delimited by "{" and "}", starting on the same line.

  ── COMMANDE 3: terraform plan ───────────────────────────────────────────────
  POURQUOI? Affiche CE QUE Terraform va créer/modifier/supprimer.
  Ne touche à rien. C'est un aperçu.
  RÈGLE D'OR: TOUJOURS lire le plan avant d'exécuter apply.
  QUAND? Avant chaque apply, et en PR (pour que l'équipe voie ce qui va changer).

  terraform plan

  CE QUE VOUS VOYEZ:
  ───────────────────
  Terraform used the selected providers to generate the following execution plan.
  Resource actions are indicated with the following symbols:
    + create     <- ressource à CRÉER (nouvelle)
    ~ update     <- ressource à MODIFIER (existe déjà)
    - destroy    <- ressource à SUPPRIMER (DANGER!)
    -/+ replace  <- ressource à RECRÉER (supprimer + recréer)

  Terraform will perform the following actions:

    # digitalocean_vpc.taskmanager_staging will be created
    + resource "digitalocean_vpc" "taskmanager_staging" {
        + id       = (known after apply)
        + ip_range = "10.0.0.0/16"
        + name     = "taskmanager-staging-vpc"
        + region   = "fra1"
      }

    # digitalocean_firewall.taskmanager_staging will be created
    + resource "digitalocean_firewall" "taskmanager_staging" {
        + id   = (known after apply)
        + name = "taskmanager-staging-fw"
        + tags = ["taskmanager-staging"]
        ...
      }

    # digitalocean_droplet.taskmanager_staging will be created
    + resource "digitalocean_droplet" "taskmanager_staging" {
        + id          = (known after apply)
        + image       = "ubuntu-22-04-x64"
        + ipv4_address = (known after apply)
        + name        = "taskmanager-staging"
        + region      = "fra1"
        + size        = "s-2vcpu-4gb"
        ...
      }

    # digitalocean_record.staging_api will be created
    + resource "digitalocean_record" "staging_api" {
        ...
      }

  Plan: 6 to add, 0 to change, 0 to destroy.

  Changes to Outputs:
    + staging_ip   = (known after apply)
    + staging_url  = "https://api-staging.equipe.com"
    + ssh_command  = (known after apply)

  # "6 to add, 0 to change, 0 to destroy" = créer 6 ressources, rien de supprimé.
  # Alice vérifie que tout est attendu avant de continuer.

  SAUVEGARDER LE PLAN (bonne pratique en équipe):
  ─────────────────────────────────────────────────
  terraform plan -out=staging.tfplan   # Sauvegarder le plan dans un fichier
  terraform apply staging.tfplan       # Appliquer EXACTEMENT ce plan

  ── COMMANDE 4: terraform apply ──────────────────────────────────────────────
  POURQUOI? Crée réellement les ressources sur DigitalOcean.
  QUAND? Après avoir vérifié le plan.

  terraform apply
  # Ou, pour appliquer sans demande de confirmation (CI/CD):
  terraform apply -auto-approve

  CE QUE VOUS VOYEZ:
  ───────────────────
  Do you want to perform these actions?
    Terraform will perform the actions described above.
    Only 'yes' will be accepted to approve.

  Enter a value: yes

  digitalocean_vpc.taskmanager_staging: Creating...
  digitalocean_vpc.taskmanager_staging: Creation complete after 3s [id=12345678]
  digitalocean_firewall.taskmanager_staging: Creating...
  digitalocean_firewall.taskmanager_staging: Creation complete after 5s
  digitalocean_droplet.taskmanager_staging: Creating...
  digitalocean_droplet.taskmanager_staging: Still creating... [30s elapsed]
  digitalocean_droplet.taskmanager_staging: Still creating... [1m elapsed]
  digitalocean_droplet.taskmanager_staging: Creation complete after 1m23s [id=987654]
  digitalocean_volume.staging_data: Creating...
  digitalocean_volume.staging_data: Creation complete after 4s
  digitalocean_volume_attachment.staging_data: Creating...
  digitalocean_volume_attachment.staging_data: Creation complete after 2s
  digitalocean_record.staging_api: Creating...
  digitalocean_record.staging_api: Creation complete after 2s

  Apply complete! Resources: 6 added, 0 changed, 0 destroyed.

  Outputs:
  staging_ip     = "164.90.154.23"
  staging_url    = "https://api-staging.equipe.com"
  ssh_command    = "ssh ubuntu@164.90.154.23"

  # Le serveur est créé! Alice peut maintenant s'y connecter:
  ssh ubuntu@164.90.154.23
  # -> Welcome to Ubuntu 22.04 LTS...

  RÉCUPÉRER LES OUTPUTS PLUS TARD:
  ──────────────────────────────────
  terraform output                    # Voir tous les outputs
  terraform output staging_ip         # Voir un output spécifique
  terraform output -raw staging_ip    # Sans guillemets (pour scripts)
  # -> 164.90.154.23

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 3.7 — SCÉNARIO: BOB VEUT UN SERVEUR DE TEST TEMPORAIRE
────────────────────────────────────────────────────────────────────────────────

  CONTEXTE:
  ──────────
  Bob développe la feature "filtrage par dates" et veut tester sur un vrai
  serveur avant d'ouvrir sa PR. Il ne veut pas "polluer" le staging partagé.

  POURQUOI TERRAFORM EST PARFAIT POUR ÇA?
  ─────────────────────────────────────────
  Bob peut créer son propre serveur en 3 minutes, le tester, puis le détruire.
  Coût: ~0.04$/heure (serveur détruit après le test = coût négligeable).

  BOB DANS SON TERMINAL:
  ────────────────────────
  cd infrastructure/terraform/environments/
  cp -r staging bob-test-filtrage         # Copier le dossier staging

  # Modifier les variables pour ce serveur temporaire:
  nano bob-test-filtrage/terraform.tfvars
  # Changer:
  # droplet_size = "s-1vcpu-2gb"  (moins puissant = moins cher)
  # environment  = "test"

  # Initialiser et déployer:
  cd bob-test-filtrage/
  terraform init
  terraform apply -auto-approve

  CE QUE VOUS VOYEZ:
  ───────────────────
  Apply complete! Resources: 5 added, 0 changed, 0 destroyed.
  Outputs:
  staging_ip = "164.90.200.99"
  ssh_command = "ssh ubuntu@164.90.200.99"

  # Bob teste sa feature sur ce serveur, valide que ça fonctionne.
  # Quand Bob a fini:

  terraform destroy -auto-approve

  CE QUE VOUS VOYEZ:
  ───────────────────
  digitalocean_record.staging_api: Destroying...
  digitalocean_volume_attachment.staging_data: Destroying...
  digitalocean_droplet.taskmanager_staging: Destroying...
  digitalocean_droplet.taskmanager_staging: Still destroying... [30s elapsed]
  digitalocean_droplet.taskmanager_staging: Destruction complete after 45s
  digitalocean_volume.staging_data: Destroying...
  digitalocean_volume.staging_data: Destruction complete after 3s

  Destroy complete! Resources: 5 destroyed.

  # Toutes les ressources supprimées. Aucune facture après ce point.
  # Bob supprime le dossier:
  cd .. && rm -rf bob-test-filtrage/

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 3.8 — GÉRER LES MODIFICATIONS: TERRAFORM STATE EN DÉTAIL
────────────────────────────────────────────────────────────────────────────────

  QU'EST-CE QUE LE STATE TERRAFORM?
  ────────────────────────────────────
  Terraform garde une "photo" de l'infrastructure créée dans terraform.tfstate.
  Ce fichier JSON contient l'ID, les propriétés et les métadonnées de chaque
  ressource créée. C'est la "mémoire" de Terraform.

  SANS STATE: Terraform ne saurait pas que le serveur 987654 existe déjà.
  -> Il en créerait un nouveau à chaque apply!

  VISUALISER LE STATE:
  ─────────────────────
  terraform state list         # Lister toutes les ressources dans le state

  CE QUE VOUS VOYEZ:
  ───────────────────
  data.digitalocean_ssh_key.alice
  data.digitalocean_ssh_key.deploy
  digitalocean_droplet.taskmanager_staging
  digitalocean_firewall.taskmanager_staging
  digitalocean_monitor_alert.staging_cpu
  digitalocean_record.staging_api
  digitalocean_volume.staging_data
  digitalocean_volume_attachment.staging_data
  digitalocean_vpc.taskmanager_staging

  terraform state show digitalocean_droplet.taskmanager_staging  # Détail d'une ressource

  CE QUE VOUS VOYEZ:
  ───────────────────
  # digitalocean_droplet.taskmanager_staging:
  resource "digitalocean_droplet" "taskmanager_staging" {
      backups              = false
      id                   = "987654"
      image                = "ubuntu-22-04-x64"
      ipv4_address         = "164.90.154.23"
      name                 = "taskmanager-staging"
      region               = "fra1"
      size                 = "s-2vcpu-4gb"
      status               = "active"
      ...
  }

  MODIFIER UNE RESSOURCE EXISTANTE:
  ───────────────────────────────────
  # Alice veut augmenter la RAM du serveur staging (s-2vcpu-4gb -> s-4vcpu-8gb):

  # 1. Modifier la variable dans terraform.tfvars:
  droplet_size = "s-4vcpu-8gb"

  # 2. Voir ce que Terraform va faire:
  terraform plan

  CE QUE VOUS VOYEZ:
  ───────────────────
  # digitalocean_droplet.taskmanager_staging must be replaced
  -/+ resource "digitalocean_droplet" "taskmanager_staging" {
        ~ id   = "987654" -> (known after apply)
        ~ size = "s-2vcpu-4gb" -> "s-4vcpu-8gb"
        ...
      }

  Plan: 1 to add, 0 to change, 1 to destroy.

  # -/+ = DigitalOcean DOIT recréer le serveur pour changer sa taille.
  # Alice voit l'avertissement: le serveur sera indisponible pendant ~2 minutes.
  # Elle prévient l'équipe avant d'appliquer en production.

  IMPORTER UNE RESSOURCE EXISTANTE DANS LE STATE:
  ─────────────────────────────────────────────────
  # Cas: Un serveur a été créé manuellement (pas via Terraform).
  # Alice veut le gérer avec Terraform sans le recréer.

  terraform import digitalocean_droplet.taskmanager_staging 987654
  # Terraform ajoute cette ressource dans son state.
  # Désormais: terraform plan inclura ce serveur dans ses calculs.

  SUPPRIMER UNE RESSOURCE DU STATE SANS LA DÉTRUIRE:
  ────────────────────────────────────────────────────
  # Cas: Bob veut "sortir" son serveur de test du state sans le supprimer.

  terraform state rm digitalocean_droplet.taskmanager_staging
  # Terraform "oublie" ce serveur. Il existe toujours sur DigitalOcean.
  # Utile pour: migrer des ressources entre states, ou exclure temporairement.

  DÉPLACER UNE RESSOURCE DANS LE STATE:
  ────────────────────────────────────────
  # Cas: Alice a renommé une ressource dans main.tf.
  # Sans state mv: Terraform destroy l'ancienne + create la nouvelle.
  # Avec state mv: Terraform "renomme" dans le state, pas de recréation.

  terraform state mv \
    digitalocean_droplet.taskmanager_staging \
    module.staging_server.digitalocean_droplet.this
  # Utile lors d'une refactorisation qui ajoute des modules.

  PARTAGER LE STATE EN ÉQUIPE (BACKEND TERRAFORM CLOUD):
  ────────────────────────────────────────────────────────
  # POURQUOI PARTAGER LE STATE?
  # Si le state est LOCAL (terraform.tfstate sur la machine d'Alice):
  # -> Bob fait terraform apply -> Terraform ne sait pas ce qu'Alice a créé
  # -> Il recrée tout! DÉSASTRE.

  # Solution 1: Terraform Cloud (gratuit pour équipes < 5 personnes):
  # 1. Créer un compte: app.terraform.io
  # 2. Créer une organisation: "equipe-taskmanager"
  # 3. Créer un workspace: "taskmanager-staging"
  # 4. Configurer le backend dans main.tf (déjà fait dans notre exemple ci-dessus)
  # 5. terraform login (s'authentifier)
  # 6. terraform init (configure le backend cloud)
  # -> Le state est maintenant sur Terraform Cloud, partagé et verrouillé.

  # Solution 2: Backend S3 (si vous avez AWS):
  # backend "s3" {
  #   bucket         = "taskmanager-terraform-state"
  #   key            = "staging/terraform.tfstate"
  #   region         = "eu-west-3"
  #   encrypt        = true
  #   dynamodb_table = "terraform-state-lock"  <- Verrouillage distribué
  # }

  VERROUILLAGE DU STATE:
  ───────────────────────
  # En équipe: deux personnes peuvent faire terraform apply simultanément.
  # Sans verrou: les deux écrivent dans le state -> corruption!
  # Terraform Cloud et le backend S3+DynamoDB implémentent un verrou automatique.
  # Si Alice fait terraform apply, Bob voit:
  # Error: Error locking state: Error acquiring the state lock.
  # State is currently locked by another user: alice@equipe.com (started 2 minutes ago)
  # Bob attend qu'Alice finisse.

================================================================================
PARTIE 4 — ANSIBLE: CONFIGURER LES SERVEURS (ALICE)
================================================================================

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 4.1 — CONFIGURER ANSIBLE.CFG
────────────────────────────────────────────────────────────────────────────────

  nano infrastructure/ansible/ansible.cfg

  Contenu:
  [defaults]
  # Fichier d'inventaire par défaut (chemin relatif à ce fichier)
  inventory          = inventory/staging.ini

  # Utilisateur SSH par défaut sur les serveurs cibles
  remote_user        = ubuntu

  # Clé SSH à utiliser pour les connexions
  private_key_file   = ~/.ssh/id_ed25519_github

  # Ne pas vérifier la clé d'hôte SSH (pratique en dev, à activer en prod)
  host_key_checking  = False
  # En production:
  # host_key_checking = True
  # ssh_args = -o StrictHostKeyChecking=yes

  # Nombre de connexions parallèles (hôtes traités simultanément)
  forks              = 5

  # Répertoire des roles
  roles_path         = roles

  # Fichier de log Ansible (pratique pour débogage)
  log_path           = /tmp/ansible.log

  # Activer les couleurs dans le terminal
  force_color        = True

  # Stratégie d'exécution (linear = par défaut, free = en parallèle)
  strategy           = linear

  # Timeout de connexion SSH en secondes
  timeout            = 30

  # Fichier pour stocker le cache des facts (infos sur les serveurs)
  fact_caching             = jsonfile
  fact_caching_connection  = /tmp/ansible_facts_cache
  fact_caching_timeout     = 3600    # 1 heure

  # Retries en cas d'erreur de connexion
  retry_files_enabled = True
  retry_files_save_path = /tmp/ansible_retry

  [privilege_escalation]
  # Permettre à Ansible de faire "sudo" sur les serveurs cibles
  become       = True       # Utiliser sudo par défaut
  become_method = sudo
  become_user   = root      # Devenir root après connexion SSH
  become_ask_pass = False   # Pas de mot de passe sudo (clé SSH)

  [ssh_connection]
  # Réutiliser les connexions SSH (plus rapide pour beaucoup de tâches)
  ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o ForwardAgent=yes
  pipelining = True         # Exécuter plusieurs commandes en une connexion SSH
  # pipelining = True réduit le nombre de connexions SSH de ~50%

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 4.2 — CRÉER L'INVENTAIRE (LISTE DES SERVEURS)
────────────────────────────────────────────────────────────────────────────────

  POURQUOI UN INVENTAIRE?
  ────────────────────────
  Ansible a besoin de savoir sur QUELS serveurs travailler.
  L'inventaire = la liste avec les IPs, utilisateurs, groupes et variables.

  nano infrastructure/ansible/inventory/staging.ini

  Contenu:
  # ═══════════════════════════════════════════════════════════════════════
  # Inventaire Ansible STAGING
  # ═══════════════════════════════════════════════════════════════════════
  # Mis à jour automatiquement par le script terraform_inventory.py
  # (ou manuellement après terraform output)

  # ── GROUPE: serveurs web (Flask + Nginx) ─────────────────────────────
  [web]
  taskmanager-staging ansible_host=164.90.154.23

  # ── GROUPE: serveurs de base de données ───────────────────────────────
  # En staging: la DB est sur le même serveur que le web.
  # En production: serveur séparé.
  [db]
  taskmanager-staging ansible_host=164.90.154.23

  # ── GROUPE: tous les serveurs de l'environnement ──────────────────────
  [staging:children]
  web
  db

  # ── VARIABLES PAR HÔTE ────────────────────────────────────────────────
  [all:vars]
  ansible_user=ubuntu
  ansible_python_interpreter=/usr/bin/python3

  [web:vars]
  app_port=5000
  nginx_port=80

  [db:vars]
  postgres_port=5432
  redis_port=6379

  INVENTAIRE DYNAMIQUE (DEPUIS TERRAFORM):
  ─────────────────────────────────────────
  # POURQUOI DYNAMIQUE?
  # L'IP du serveur change à chaque terraform destroy/apply.
  # Mettre l'IP en dur dans le .ini = obligation de mettre à jour à la main.
  # Un inventaire dynamique lit l'IP depuis terraform output automatiquement.

  nano infrastructure/ansible/inventory/dynamic/terraform_inventory.py

  Contenu:
  #!/usr/bin/env python3
  """
  Inventaire dynamique Ansible: lit les outputs Terraform.
  Usage: ansible-playbook -i inventory/dynamic/terraform_inventory.py site.yml
  """
  import json
  import subprocess
  import sys

  def get_terraform_outputs(env="staging"):
      """Lire les outputs Terraform pour un environnement."""
      tf_dir = f"../../terraform/environments/{env}"
      result = subprocess.run(
          ["terraform", "output", "-json"],
          cwd=tf_dir,
          capture_output=True,
          text=True
      )
      if result.returncode != 0:
          return {}
      return json.loads(result.stdout)

  def build_inventory():
      """Construire l'inventaire Ansible depuis les outputs Terraform."""
      staging = get_terraform_outputs("staging")
      prod    = get_terraform_outputs("production")

      inventory = {
          "staging": {
              "hosts": [],
              "vars": {"environment": "staging"}
          },
          "production": {
              "hosts": [],
              "vars": {"environment": "production"}
          },
          "_meta": {
              "hostvars": {}
          }
      }

      # Ajouter le serveur staging si l'output existe
      if "staging_ip" in staging:
          ip = staging["staging_ip"]["value"]
          hostname = "taskmanager-staging"
          inventory["staging"]["hosts"].append(hostname)
          inventory["_meta"]["hostvars"][hostname] = {
              "ansible_host":  ip,
              "ansible_user":  "ubuntu",
              "app_env":       "staging",
          }

      # Ajouter le serveur production si l'output existe
      if "production_ip" in prod:
          ip = prod["production_ip"]["value"]
          hostname = "taskmanager-production"
          inventory["production"]["hosts"].append(hostname)
          inventory["_meta"]["hostvars"][hostname] = {
              "ansible_host":  ip,
              "ansible_user":  "ubuntu",
              "app_env":       "production",
          }

      return inventory

  if __name__ == "__main__":
      print(json.dumps(build_inventory(), indent=2))

  chmod +x infrastructure/ansible/inventory/dynamic/terraform_inventory.py

  # Tester:
  python3 infrastructure/ansible/inventory/dynamic/terraform_inventory.py
  # -> {"staging": {"hosts": ["taskmanager-staging"], ...}, ...}

  # Utiliser avec Ansible:
  ansible -i inventory/dynamic/terraform_inventory.py all --list-hosts
  # -> hosts (1):
  # ->   taskmanager-staging

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 4.3 — VARIABLES ANSIBLE: GROUP_VARS ET HOST_VARS
────────────────────────────────────────────────────────────────────────────────

  POURQUOI GROUP_VARS ET HOST_VARS?
  ───────────────────────────────────
  Dans les playbooks, avoir des variables en dur = problème.
  group_vars/ = variables communes à tout un groupe (staging, prod...).
  host_vars/  = variables spécifiques à un seul serveur.
  Ansible charge automatiquement ces fichiers selon le groupe/hôte.

  nano infrastructure/ansible/group_vars/all.yml

  Contenu:
  # ═══════════════════════════════════════════════════════════════════════
  # Variables communes à TOUS les serveurs (staging + production)
  # ═══════════════════════════════════════════════════════════════════════

  # ── Application ──────────────────────────────────────────────────────
  app_name: taskmanager
  app_user: deploy                      # Utilisateur système pour l'app
  app_group: deploy
  app_dir: /opt/taskmanager             # Dossier de l'application
  app_repo: git@github.com:equipe/taskmanager.git

  # ── Python ────────────────────────────────────────────────────────────
  python_version: "3.11"
  python_bin: /usr/bin/python3
  venv_dir: "{{ app_dir }}/venv"

  # ── Flask ─────────────────────────────────────────────────────────────
  flask_port: 5000
  gunicorn_workers: 3                   # Nombre de workers Gunicorn
  gunicorn_timeout: 120                 # Timeout en secondes

  # ── PostgreSQL ────────────────────────────────────────────────────────
  postgres_version: "15"
  postgres_port: 5432
  postgres_db: taskmanager
  postgres_user: taskmanager
  # postgres_password: défini dans secrets.yml (chiffré avec Vault)

  # ── Redis ─────────────────────────────────────────────────────────────
  redis_port: 6379
  redis_maxmemory: "256mb"
  redis_maxmemory_policy: "allkeys-lru"

  # ── Nginx ─────────────────────────────────────────────────────────────
  nginx_port: 80
  nginx_ssl_port: 443
  nginx_worker_processes: auto
  nginx_worker_connections: 1024

  # ── Monitoring ────────────────────────────────────────────────────────
  prometheus_port: 9090
  grafana_port: 3000
  node_exporter_port: 9100

  # ── Système ────────────────────────────────────────────────────────────
  timezone: "Europe/Paris"
  ntp_servers:
    - "0.fr.pool.ntp.org"
    - "1.fr.pool.ntp.org"

  # Packages système communs à tous les serveurs
  common_packages:
    - git
    - curl
    - wget
    - vim
    - htop
    - unzip
    - fail2ban
    - ufw
    - python3
    - python3-pip
    - python3-venv

  nano infrastructure/ansible/group_vars/staging.yml

  Contenu:
  # Variables spécifiques à l'environnement STAGING
  flask_env: staging
  flask_debug: "1"
  domain: api-staging.equipe.com
  log_level: DEBUG
  gunicorn_workers: 2   # Moins de workers qu'en prod (moins de charge)
  enable_ssl: false     # Pas de SSL en staging (simplifier les tests)
  app_version: "{{ lookup('env', 'APP_VERSION') | default('develop') }}"
  backup_enabled: false
  monitoring_enabled: false    # Pas de Prometheus en staging (économie)

  nano infrastructure/ansible/group_vars/production.yml

  Contenu:
  # Variables spécifiques à l'environnement PRODUCTION
  flask_env: production
  flask_debug: "0"
  domain: api.equipe.com
  log_level: WARNING
  gunicorn_workers: 4   # Plus de workers pour tenir la charge
  enable_ssl: true      # SSL obligatoire en production
  app_version: "{{ lookup('env', 'APP_VERSION') | default('latest') }}"
  backup_enabled: true
  monitoring_enabled: true
  ssl_certificate: /etc/letsencrypt/live/api.equipe.com/fullchain.pem
  ssl_certificate_key: /etc/letsencrypt/live/api.equipe.com/privkey.pem

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 4.4 — ANSIBLE VAULT: CHIFFRER LES SECRETS (ALICE)
────────────────────────────────────────────────────────────────────────────────

  POURQUOI ANSIBLE VAULT?
  ────────────────────────
  Les mots de passe de base de données, les clés secrètes Flask, les tokens
  API ne doivent JAMAIS apparaître en clair dans Git.
  Mais Ansible a besoin de ces valeurs pour configurer les serveurs.

  SANS VAULT (DANGEREUX):
    # group_vars/staging.yml
    postgres_password: "monMotDePasseEnClair"   <- VISIBLE DANS GIT = CATASTROPHE

  AVEC VAULT:
    # vars/staging_secrets.yml — fichier CHIFFRÉ dans Git
    postgres_password: ENCRYPTED_VALUE_HERE
    -> Personne ne peut lire ce fichier sans le mot de passe du vault.
    -> Ansible déchiffre automatiquement au moment d'exécuter le playbook.

  QUAND UTILISER VAULT?
  ──────────────────────
  -> Pour TOUT ce qui est un secret: mots de passe, tokens, clés API, certificats.
  -> Jamais pour des valeurs non sensibles (ports, noms de fichiers, etc.)

  COMMENT CRÉER UN FICHIER CHIFFRÉ AVEC VAULT?
  ──────────────────────────────────────────────

  # ALICE DANS SON TERMINAL (depuis infrastructure/ansible/):

  # Créer et chiffrer le fichier des secrets staging en une commande:
  ansible-vault create vars/staging_secrets.yml

  CE QUE VOUS VOYEZ:
  ───────────────────
  New Vault password:
  Confirm New Vault password:
  # -> Ansible ouvre nano (ou l'éditeur configuré).
  # Alice tape le contenu YAML des secrets:

  # Contenu à taper dans l'éditeur:
  ---
  # Secrets chiffrés pour l'environnement STAGING
  # Ce fichier est chiffré par Ansible Vault — ne JAMAIS déchiffrer en dehors
  # d'une session sécurisée et ne JAMAIS committer la version déchiffrée.

  # Flask
  flask_secret_key: "staging-secret-key-genere-avec-openssl-rand-hex-32"

  # PostgreSQL
  postgres_password: "staging-postgres-mdp-complexe-2024"

  # Redis
  redis_password: ""   # En staging: pas de mot de passe Redis (réseau isolé)

  # DigitalOcean (pour les scripts de maintenance)
  do_api_token: "dop_v1_VOTRE_TOKEN"

  # Slack (pour les alertes)
  slack_webhook_url: "https://hooks.slack.com/services/XXX/YYY/ZZZ"

  # Sauvegarder et fermer: Ctrl+O, Entrée, Ctrl+X

  CE QUE VOUS VOYEZ DANS LE FICHIER APRÈS CRÉATION:
  ───────────────────────────────────────────────────
  cat vars/staging_secrets.yml

  $ANSIBLE_VAULT;1.1;AES256
  38623634643239323136393538333862633635313633353839333664643032363737333834376537
  3162636437633039636430653663373764356166653065330a383930313230623362373464353932
  39626561383737633534393938653035393534636335613566663464313735373461313234313335
  ...
  # C'est du chiffrement AES256. Illisible sans le mot de passe.

  CRÉER LES SECRETS DE PRODUCTION (mot de passe différent!):
  ───────────────────────────────────────────────────────────
  ansible-vault create vars/prod_secrets.yml
  # Utiliser un mot de passe DIFFÉRENT du staging (sécurité)

  # Contenu:
  ---
  flask_secret_key: "prod-secret-key-TRES-LONGUE-ET-ALEATOIRE-openssl-rand-hex-64"
  postgres_password: "prod-mdp-postgres-ULTRA-COMPLEXE-#@!2024"
  redis_password: "prod-mdp-redis-complexe"
  do_api_token: "dop_v1_TOKEN_PROD"
  slack_webhook_url: "https://hooks.slack.com/services/PROD/YYY/ZZZ"

  OPÉRATIONS COURANTES SUR LES FICHIERS VAULT:
  ─────────────────────────────────────────────

  # Voir le contenu déchiffré (pour vérification):
  ansible-vault view vars/staging_secrets.yml
  # -> Demande le mot de passe, affiche le YAML en clair.

  # Modifier un fichier chiffré:
  ansible-vault edit vars/staging_secrets.yml
  # -> Déchiffre en RAM, ouvre l'éditeur, rechiffre à la sauvegarde.

  # Chiffrer un fichier existant (si vous avez créé un fichier en clair par erreur):
  ansible-vault encrypt vars/oublie.yml
  # -> Le fichier est maintenant chiffré.

  # Déchiffrer un fichier (ATTENTION: crée un fichier en clair!):
  ansible-vault decrypt vars/staging_secrets.yml
  # -> NE FAIRE QUE LOCALEMENT, ne jamais committer le déchiffré.

  # Changer le mot de passe du vault:
  ansible-vault rekey vars/staging_secrets.yml
  # -> Demande l'ancien mot de passe, puis le nouveau.

  # Chiffrer seulement UNE VALEUR (inline dans un fichier YAML non chiffré):
  ansible-vault encrypt_string 'ma-valeur-secrete' --name 'postgres_password'

  CE QUE VOUS VOYEZ:
  ───────────────────
  postgres_password: !vault |
            $ANSIBLE_VAULT;1.1;AES256
            61393839363437343836663135323...
  # Vous pouvez coller ça directement dans group_vars/production.yml

  GÉRER LE MOT DE PASSE DU VAULT SANS LE TAPER À CHAQUE FOIS:
  ─────────────────────────────────────────────────────────────

  # Option 1: Fichier de mot de passe (pour machines de dev personnelles):
  echo "mon-mot-de-passe-vault-staging" > ~/.vault_pass_staging
  chmod 600 ~/.vault_pass_staging
  # Ce fichier ne doit JAMAIS être dans Git!

  # Configurer ansible.cfg pour l'utiliser automatiquement:
  # [defaults]
  # vault_password_file = ~/.vault_pass_staging

  # Option 2: Variable d'environnement (pour CI/CD):
  export ANSIBLE_VAULT_PASSWORD_FILE=/run/secrets/vault_pass
  # Ou passer le mot de passe au lancement:
  ansible-playbook site.yml --vault-password-file ~/.vault_pass_staging

  # Option 3: Plusieurs vault IDs (staging et prod avec mots de passe différents):
  ansible-vault create --vault-id staging@prompt vars/staging_secrets.yml
  ansible-vault create --vault-id prod@prompt vars/prod_secrets.yml

  # Lancer avec les deux:
  ansible-playbook site.yml \
    --vault-id staging@~/.vault_pass_staging \
    --vault-id prod@~/.vault_pass_prod

  STOCKER LE MOT DE PASSE EN CI/CD (GitHub Actions):
  ────────────────────────────────────────────────────
  # Sur GitHub: Settings -> Secrets -> New secret
  # Name: ANSIBLE_VAULT_PASSWORD_STAGING
  # Value: le mot de passe du vault

  # Dans le workflow GitHub Actions:
  # - name: Déchiffrer les secrets Ansible
  #   run: echo "${{ secrets.ANSIBLE_VAULT_PASSWORD_STAGING }}" > /tmp/vault_pass
  #        chmod 600 /tmp/vault_pass
  # - name: Lancer le playbook
  #   run: ansible-playbook site.yml --vault-password-file /tmp/vault_pass

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 4.5 — CRÉER LES ROLES ANSIBLE (ALICE + CLAIRE)
────────────────────────────────────────────────────────────────────────────────

  POURQUOI LES ROLES?
  ────────────────────
  Imaginez un playbook monolithique de 500 lignes.
  "Installe Python" + "Configure PostgreSQL" + "Déploie Flask" + "Configure Nginx"
  tout dans un seul fichier -> impossible à maintenir, tester ou réutiliser.

  Un role = un dossier avec une structure standardisée qui regroupe:
  -> Les tâches (tasks/)
  -> Les handlers (handlers/)
  -> Les templates de fichiers (templates/)
  -> Les fichiers statiques (files/)
  -> Les variables par défaut (defaults/)
  -> Les variables obligatoires (vars/)
  -> Les tests du role (tests/)

  Chaque role fait UNE CHOSE bien. Pour configurer un serveur, on compose:
  - role "common"      -> configuration de base Ubuntu
  - role "python"      -> installation de Python
  - role "postgresql"  -> installation et config de PostgreSQL
  - role "redis"       -> installation et config de Redis
  - role "flask_app"   -> déploiement de l'application
  - role "nginx"       -> installation et config de Nginx

  ═══════════════════════════════════════════════════════════════════════════
  ROLE 1: common — Configuration de base Ubuntu (Alice)
  ═══════════════════════════════════════════════════════════════════════════

  STRUCTURE DU ROLE:
  ───────────────────
  # Créer la structure avec ansible-galaxy (outil intégré à Ansible):
  cd infrastructure/ansible/
  ansible-galaxy role init roles/common

  CE QUE ÇA CRÉE:
  ────────────────
  roles/common/
  ├── defaults/
  │   └── main.yml    <- Variables avec valeurs par défaut (modifiables)
  ├── files/          <- Fichiers statiques à copier tels quels
  ├── handlers/
  │   └── main.yml    <- Handlers (tâches déclenchées par notify)
  ├── meta/
  │   └── main.yml    <- Métadonnées du role (auteur, dépendances)
  ├── tasks/
  │   └── main.yml    <- Tâches du role (point d'entrée)
  ├── templates/      <- Templates Jinja2 (fichiers de config avec variables)
  ├── tests/
  │   ├── inventory   <- Inventaire de test minimal
  │   └── test.yml    <- Playbook de test du role
  └── vars/
      └── main.yml    <- Variables immuables du role

  ÉCRIRE LES TÂCHES DU ROLE COMMON:
  ───────────────────────────────────
  nano roles/common/tasks/main.yml

  Contenu:
  ---
  # ═══════════════════════════════════════════════════════════════════════
  # Role: common — Configuration de base de tous les serveurs Ubuntu
  # ═══════════════════════════════════════════════════════════════════════

  # ── 1. Configurer le timezone ─────────────────────────────────────────
  # POURQUOI? Les logs doivent afficher l'heure locale (Paris, pas UTC).
  # Le module "timezone" est idempotent: ne fait rien si déjà configuré.
  - name: Configurer le timezone
    community.general.timezone:
      name: "{{ timezone }}"
    # timezone = "Europe/Paris" (défini dans group_vars/all.yml)

  # ── 2. Mettre à jour la liste des paquets apt ─────────────────────────
  # POURQUOI? S'assurer que les paquets installés ensuite sont à jour.
  # update_cache: yes = équivalent à "sudo apt-get update"
  # cache_valid_time: 3600 = ne pas re-télécharger si < 1h (idempotence)
  - name: Mettre à jour le cache apt
    apt:
      update_cache: yes
      cache_valid_time: 3600

  # ── 3. Upgrader les paquets de sécurité ───────────────────────────────
  # POURQUOI? Les serveurs doivent toujours avoir les correctifs de sécurité.
  # upgrade: safe = seulement les mises à jour qui ne suppriment pas de paquets.
  - name: Appliquer les mises à jour de sécurité
    apt:
      upgrade: safe
    register: apt_upgrade_result
    # register = stocker le résultat dans une variable pour usage ultérieur

  # ── 4. Installer les paquets communs ─────────────────────────────────
  # POURQUOI? Un ensemble minimal d'outils pour tous les serveurs.
  # Le module apt avec une liste gère tous les paquets en une seule commande.
  - name: Installer les paquets communs
    apt:
      name: "{{ common_packages }}"
      state: present    # "present" = installer si absent, ne rien faire sinon
    # state: latest     = toujours mettre à jour vers la dernière version
    # state: absent     = désinstaller si présent

  # ── 5. Configurer fail2ban (protection contre les attaques SSH) ────────
  # POURQUOI? Fail2ban bloque les IPs qui tentent trop de connexions SSH.
  # Protection basique contre les attaques par force brute.
  - name: Créer la configuration fail2ban SSH
    copy:
      content: |
        [sshd]
        enabled  = true
        port     = ssh
        filter   = sshd
        maxretry = 5
        bantime  = 3600
        findtime = 600
      dest: /etc/fail2ban/jail.d/sshd.conf
      mode: '0644'
    notify: Redémarrer fail2ban
    # notify = "si ce fichier a changé, déclencher le handler 'Redémarrer fail2ban'"

  # ── 6. Créer l'utilisateur applicatif ────────────────────────────────
  # POURQUOI? L'application Flask ne doit PAS tourner sous root.
  # Un utilisateur dédié "deploy" limite les dégâts si l'app est compromise.
  - name: Créer l'utilisateur applicatif
    user:
      name: "{{ app_user }}"        # "deploy"
      group: "{{ app_group }}"      # "deploy"
      shell: /bin/bash
      home: "/home/{{ app_user }}"
      create_home: yes
      state: present

  # ── 7. Créer le dossier de l'application ─────────────────────────────
  - name: Créer le dossier de l'application
    file:
      path: "{{ app_dir }}"         # /opt/taskmanager
      state: directory              # "directory" = créer si absent
      owner: "{{ app_user }}"
      group: "{{ app_group }}"
      mode: '0755'

  # ── 8. Créer les dossiers de logs ────────────────────────────────────
  - name: Créer les dossiers de logs
    file:
      path: "{{ item }}"
      state: directory
      owner: "{{ app_user }}"
      group: "{{ app_group }}"
      mode: '0755'
    loop:                            # loop = répéter la tâche pour chaque item
      - /var/log/taskmanager
      - /var/log/nginx/taskmanager
      - "{{ app_dir }}/logs"

  # ── 9. Configurer les limites système pour l'utilisateur deploy ───────
  # POURQUOI? Les applications sous charge ont besoin d'ouvrir beaucoup de
  # fichiers (connexions, sockets). Les limites par défaut Ubuntu sont trop basses.
  - name: Configurer les limites système
    pam_limits:
      domain: "{{ app_user }}"
      limit_type: "{{ item.type }}"
      limit_item: "{{ item.item }}"
      value: "{{ item.value }}"
    loop:
      - { type: soft, item: nofile, value: "65536" }   # fichiers ouverts (soft)
      - { type: hard, item: nofile, value: "65536" }   # fichiers ouverts (hard)
      - { type: soft, item: nproc,  value: "4096"  }   # processus max (soft)
      - { type: hard, item: nproc,  value: "4096"  }   # processus max (hard)

  # ── 10. Configurer le pare-feu UFW ───────────────────────────────────
  # POURQUOI? Ajouter une couche de protection au niveau OS
  # (en plus du firewall DigitalOcean configuré par Terraform).
  - name: Activer UFW et autoriser SSH
    ufw:
      rule: allow
      port: "22"
      proto: tcp

  - name: Autoriser HTTP via UFW
    ufw:
      rule: allow
      port: "80"
      proto: tcp

  - name: Autoriser HTTPS via UFW
    ufw:
      rule: allow
      port: "443"
      proto: tcp

  - name: Activer UFW
    ufw:
      state: enabled
      policy: deny    # deny par défaut = tout bloquer sauf les règles ci-dessus

  # ── 11. Configurer les clés autorisées SSH pour deploy ────────────────
  # POURQUOI? L'utilisateur deploy doit pouvoir recevoir les déploiements
  # depuis GitHub Actions.
  - name: Configurer les clés SSH autorisées pour deploy
    authorized_key:
      user: "{{ app_user }}"
      state: present
      key: "{{ item }}"
    loop: "{{ authorized_deploy_keys | default([]) }}"
    # authorized_deploy_keys = liste de clés publiques SSH dans group_vars

  # ── 12. Supprimer les paquets inutiles (nettoyage) ─────────────────────
  - name: Supprimer les paquets inutiles (autoremove)
    apt:
      autoremove: yes
      purge: yes

  ÉCRIRE LES HANDLERS DU ROLE COMMON:
  ─────────────────────────────────────
  nano roles/common/handlers/main.yml

  Contenu:
  ---
  # Les handlers sont des tâches SPÉCIALES:
  # -> Exécutées UNE SEULE FOIS à la fin du play (même si notifiées plusieurs fois)
  # -> Exécutées SEULEMENT si au moins une tâche qui les a notifiés a CHANGÉ quelque chose
  # -> Utile pour: redémarrer des services, recharger des configs

  - name: Redémarrer fail2ban
    service:
      name: fail2ban
      state: restarted

  - name: Recharger UFW
    command: ufw reload

  ÉCRIRE LES DEFAULTS DU ROLE COMMON:
  ──────────────────────────────────────
  nano roles/common/defaults/main.yml

  Contenu:
  ---
  # Variables par DÉFAUT du role common.
  # Peuvent être SURCHARGÉES par group_vars, host_vars, ou le playbook.
  # Priorité la plus BASSE dans Ansible.

  timezone: "Europe/Paris"
  common_packages:
    - git
    - curl
    - wget
    - vim
    - htop
    - unzip
    - fail2ban
    - ufw
    - python3
    - python3-pip
    - python3-venv
    - python3-setuptools

  app_user: deploy
  app_group: deploy
  app_dir: /opt/taskmanager
  authorized_deploy_keys: []   # Liste vide par défaut (à surcharger)

  ═══════════════════════════════════════════════════════════════════════════
  ROLE 2: postgresql — Installer et configurer PostgreSQL (Alice)
  ═══════════════════════════════════════════════════════════════════════════

  ansible-galaxy role init roles/postgresql

  nano roles/postgresql/tasks/main.yml

  Contenu:
  ---
  # ═══════════════════════════════════════════════════════════════════════
  # Role: postgresql — Installation et configuration de PostgreSQL 15
  # ═══════════════════════════════════════════════════════════════════════

  # ── 1. Ajouter le dépôt officiel PostgreSQL ──────────────────────────
  # POURQUOI? Ubuntu 22.04 fournit PostgreSQL 14. On veut la version 15.
  # Le dépôt officiel PostgreSQL fournit toujours la dernière version stable.
  - name: Ajouter la clé GPG PostgreSQL
    apt_key:
      url: https://www.postgresql.org/media/keys/ACCC4CF8.asc
      state: present

  - name: Ajouter le dépôt PostgreSQL
    apt_repository:
      repo: "deb http://apt.postgresql.org/pub/repos/apt {{ ansible_distribution_release }}-pgdg main"
      state: present
      filename: pgdg
    # ansible_distribution_release = "jammy" (nom codé d'Ubuntu 22.04)
    # Cette variable est un "fact" collecté automatiquement par Ansible
    # au début du playbook (gather_facts: yes dans site.yml).

  - name: Mettre à jour le cache apt après ajout du dépôt
    apt:
      update_cache: yes

  # ── 2. Installer PostgreSQL ───────────────────────────────────────────
  - name: Installer PostgreSQL {{ postgres_version }}
    apt:
      name:
        - "postgresql-{{ postgres_version }}"
        - "postgresql-client-{{ postgres_version }}"
        - "postgresql-contrib-{{ postgres_version }}"
        - libpq-dev           # Bibliothèque C pour psycopg2
        - python3-psycopg2    # Module Python pour Ansible (module postgresql_db)
      state: present

  # ── 3. S'assurer que PostgreSQL est démarré et activé au boot ─────────
  - name: Démarrer et activer PostgreSQL
    service:
      name: "postgresql@{{ postgres_version }}-main"
      state: started
      enabled: yes

  # ── 4. Créer la base de données ───────────────────────────────────────
  # POURQUOI le module postgresql_db et pas une commande SQL?
  # Le module est IDEMPOTENT: si la base existe déjà, il ne fait rien.
  # Une commande SQL "CREATE DATABASE" échouerait si la base existe déjà.
  - name: Créer la base de données TaskManager
    postgresql_db:
      name: "{{ postgres_db }}"         # "taskmanager"
      state: present
      encoding: UTF8
      lc_collate: "fr_FR.UTF-8"         # Tri en français (accents corrects)
      lc_ctype: "fr_FR.UTF-8"
    become: yes
    become_user: postgres               # Exécuter en tant qu'utilisateur "postgres"
    # PostgreSQL requiert d'être l'utilisateur "postgres" pour ces opérations

  # ── 5. Créer l'utilisateur PostgreSQL ────────────────────────────────
  - name: Créer l'utilisateur PostgreSQL
    postgresql_user:
      name: "{{ postgres_user }}"        # "taskmanager"
      password: "{{ postgres_password }}" # Depuis le vault (chiffré)
      state: present
      role_attr_flags: NOSUPERUSER,NOCREATEDB,NOCREATEROLE
      # NOSUPERUSER = pas d'accès root à PostgreSQL
      # NOCREATEDB = ne peut pas créer d'autres bases
      # NOCREATEROLE = ne peut pas créer d'autres rôles
    become: yes
    become_user: postgres
    no_log: true   # NE PAS afficher cette tâche dans les logs (contient un mot de passe)

  # ── 6. Donner les droits sur la base de données ───────────────────────
  - name: Donner les droits complets sur la base taskmanager
    postgresql_privs:
      db: "{{ postgres_db }}"
      privs: ALL
      type: database
      obj: "{{ postgres_db }}"
      role: "{{ postgres_user }}"
    become: yes
    become_user: postgres

  # ── 7. Configurer postgresql.conf ────────────────────────────────────
  # POURQUOI? La config par défaut PostgreSQL est trop conservative.
  # On optimise pour notre serveur (2-4 GB RAM, usage applicatif).
  - name: Configurer postgresql.conf (performances)
    template:
      src: postgresql.conf.j2            # Template Jinja2
      dest: "/etc/postgresql/{{ postgres_version }}/main/postgresql.conf"
      owner: postgres
      group: postgres
      mode: '0644'
    notify: Redémarrer PostgreSQL        # Handler: redémarrer si config change

  # ── 8. Configurer pg_hba.conf (authentification) ─────────────────────
  # POURQUOI? pg_hba.conf contrôle QUI peut se connecter à PostgreSQL
  # et COMMENT (méthode d'authentification).
  - name: Configurer pg_hba.conf
    template:
      src: pg_hba.conf.j2
      dest: "/etc/postgresql/{{ postgres_version }}/main/pg_hba.conf"
      owner: postgres
      group: postgres
      mode: '0640'
    notify: Redémarrer PostgreSQL

  # ── 9. Monter le volume de données persistant (si présent) ───────────
  # POURQUOI? Les données PostgreSQL doivent survivre si on recrée le serveur.
  # Le volume DigitalOcean créé par Terraform est monté ici.
  - name: Créer le point de montage pour les données PostgreSQL
    file:
      path: /mnt/postgres_data
      state: directory
      owner: postgres
      group: postgres
      mode: '0700'

  - name: Monter le volume de données PostgreSQL
    mount:
      path: /mnt/postgres_data
      src: /dev/sda                      # Volume attaché par Terraform
      fstype: ext4
      state: mounted
      opts: defaults,noatime
    when: ansible_devices.sda is defined  # Seulement si le volume existe
    # when = condition: exécuter seulement si cette condition est vraie

  - name: Déplacer les données PostgreSQL vers le volume persistant
    command: >
      rsync -a /var/lib/postgresql/ /mnt/postgres_data/
    args:
      creates: /mnt/postgres_data/{{ postgres_version }}
    # creates = idempotent: ne pas exécuter si ce fichier/dossier existe déjà
    when: ansible_devices.sda is defined
    notify: Redémarrer PostgreSQL

  nano roles/postgresql/templates/postgresql.conf.j2

  Contenu:
  # postgresql.conf — Généré par Ansible
  # Template: roles/postgresql/templates/postgresql.conf.j2

  # ── Connexions ──────────────────────────────────────────────────────────
  listen_addresses = 'localhost,{{ ansible_eth0.ipv4.address }}'
  # Écouter sur localhost ET l'IP privée du VPC (pour d'autres serveurs du VPC)
  port = {{ postgres_port }}
  max_connections = {{ postgres_max_connections | default(100) }}

  # ── Mémoire ────────────────────────────────────────────────────────────
  # shared_buffers: 25% de la RAM (règle générale PostgreSQL)
  # Pour 4GB RAM -> 1GB
  shared_buffers = {{ (ansible_memtotal_mb * 0.25) | int }}MB
  effective_cache_size = {{ (ansible_memtotal_mb * 0.75) | int }}MB
  work_mem = {{ (ansible_memtotal_mb / 100) | int }}MB
  maintenance_work_mem = {{ (ansible_memtotal_mb * 0.1) | int }}MB

  # ── Performance ────────────────────────────────────────────────────────
  random_page_cost = 1.1                # SSD (1.1) vs HDD (4.0)
  effective_io_concurrency = 200        # SSD: 200, HDD: 2
  wal_buffers = 16MB
  checkpoint_completion_target = 0.9
  default_statistics_target = 100

  # ── Logging ──────────────────────────────────────────────────────────
  log_destination = 'stderr'
  logging_collector = on
  log_directory = '/var/log/postgresql'
  log_filename = 'postgresql-%Y-%m-%d.log'
  log_min_duration_statement = {{ postgres_slow_query_ms | default(1000) }}
  log_line_prefix = '%t [%p]: [%l-1] user=%u,db=%d,app=%a,client=%h '
  log_lock_waits = on
  log_temp_files = 0

  # ── Locale ───────────────────────────────────────────────────────────
  lc_messages = 'fr_FR.UTF-8'
  lc_monetary = 'fr_FR.UTF-8'
  lc_numeric = 'fr_FR.UTF-8'
  lc_time = 'fr_FR.UTF-8'
  default_text_search_config = 'pg_catalog.french'

  nano roles/postgresql/templates/pg_hba.conf.j2

  Contenu:
  # pg_hba.conf — Généré par Ansible
  # Contrôle qui peut se connecter et comment

  # TYPE  DATABASE    USER          ADDRESS         METHOD
  # Connexions locales Unix (pour les outils admin):
  local   all         postgres                      peer
  local   all         all                           md5

  # Connexions TCP locales (depuis l'application Flask sur le même serveur):
  host    all         all           127.0.0.1/32    md5
  host    all         all           ::1/128         md5

  # Connexions depuis le VPC (pour futures architectures multi-serveurs):
  host    {{ postgres_db }}  {{ postgres_user }}  10.0.0.0/16  md5

  nano roles/postgresql/handlers/main.yml

  Contenu:
  ---
  - name: Redémarrer PostgreSQL
    service:
      name: "postgresql@{{ postgres_version }}-main"
      state: restarted

  - name: Recharger PostgreSQL
    service:
      name: "postgresql@{{ postgres_version }}-main"
      state: reloaded
    # reloaded = recharger la config sans couper les connexions
    # restarted = couper et redémarrer (connexions interrompues)

  nano roles/postgresql/defaults/main.yml

  Contenu:
  ---
  postgres_version: "15"
  postgres_port: 5432
  postgres_db: taskmanager
  postgres_user: taskmanager
  postgres_max_connections: 100
  postgres_slow_query_ms: 1000

  ═══════════════════════════════════════════════════════════════════════════
  ROLE 3: redis — Installer et configurer Redis (Alice)
  ═══════════════════════════════════════════════════════════════════════════

  ansible-galaxy role init roles/redis

  nano roles/redis/tasks/main.yml

  Contenu:
  ---
  - name: Installer Redis
    apt:
      name: redis-server
      state: present

  - name: Configurer Redis
    template:
      src: redis.conf.j2
      dest: /etc/redis/redis.conf
      owner: redis
      group: redis
      mode: '0640'
    notify: Redémarrer Redis

  - name: Démarrer et activer Redis
    service:
      name: redis-server
      state: started
      enabled: yes

  - name: Vérifier que Redis répond
    command: redis-cli ping
    register: redis_ping
    failed_when: redis_ping.stdout != "PONG"
    changed_when: false
    # changed_when: false = cette tâche ne "change" jamais (juste une vérification)
    # Ansible considère les "command" tasks comme toujours "changed"
    # sans cette option (fausse l'affichage du rapport)

  - name: Afficher le résultat du ping Redis
    debug:
      msg: "Redis répond: {{ redis_ping.stdout }}"
    # debug = afficher un message pendant l'exécution (utile pour diagnostic)

  nano roles/redis/templates/redis.conf.j2

  Contenu:
  # redis.conf — Généré par Ansible

  # ── Réseau ────────────────────────────────────────────────────────────
  bind 127.0.0.1 {{ ansible_eth0.ipv4.address }}
  port {{ redis_port }}
  protected-mode yes

  # ── Authentification ─────────────────────────────────────────────────
  {% if redis_password is defined and redis_password != "" %}
  requirepass {{ redis_password }}
  {% endif %}

  # ── Mémoire ──────────────────────────────────────────────────────────
  maxmemory {{ redis_maxmemory }}
  maxmemory-policy {{ redis_maxmemory_policy }}

  # ── Persistance ──────────────────────────────────────────────────────
  # RDB: sauvegardes périodiques sur disque
  save 900 1       # Sauvegarder si au moins 1 changement en 15 min
  save 300 10      # Sauvegarder si au moins 10 changements en 5 min
  save 60 10000    # Sauvegarder si au moins 10000 changements en 1 min
  dbfilename dump.rdb
  dir /var/lib/redis

  # ── Logs ──────────────────────────────────────────────────────────────
  loglevel notice
  logfile /var/log/redis/redis-server.log

  # ── Performance ──────────────────────────────────────────────────────
  tcp-keepalive 300
  timeout 0
  tcp-backlog 511
  hz 10
  lazyfree-lazy-eviction yes
  lazyfree-lazy-expire yes

  nano roles/redis/handlers/main.yml

  Contenu:
  ---
  - name: Redémarrer Redis
    service:
      name: redis-server
      state: restarted

  nano roles/redis/defaults/main.yml

  Contenu:
  ---
  redis_port: 6379
  redis_maxmemory: "256mb"
  redis_maxmemory_policy: "allkeys-lru"
  redis_password: ""

  ═══════════════════════════════════════════════════════════════════════════
  ROLE 4: flask_app — Déployer l'application Flask (Alice + Bob)
  ═══════════════════════════════════════════════════════════════════════════

  ansible-galaxy role init roles/flask_app

  nano roles/flask_app/tasks/main.yml

  Contenu:
  ---
  # ═══════════════════════════════════════════════════════════════════════
  # Role: flask_app — Cloner, installer et configurer l'application Flask
  # ═══════════════════════════════════════════════════════════════════════

  # ── 1. Installer Python et les outils de build ────────────────────────
  - name: Installer Python {{ python_version }} et les outils de build
    apt:
      name:
        - "python{{ python_version }}"
        - "python{{ python_version }}-venv"
        - "python{{ python_version }}-dev"
        - build-essential
        - libpq-dev            # Nécessaire pour psycopg2 (pilote PostgreSQL)
        - libffi-dev           # Nécessaire pour certains paquets Python
        - libssl-dev           # Nécessaire pour cryptographie
        - pkg-config
      state: present

  # ── 2. Cloner ou mettre à jour le code depuis GitHub ─────────────────
  # POURQUOI le module git et pas "git clone"?
  # Le module git est IDEMPOTENT: il fait git pull si le dossier existe déjà.
  # Il gère les clés SSH, les versions, les branches.
  - name: Cloner/mettre à jour le code de l'application
    git:
      repo: "{{ app_repo }}"
      dest: "{{ app_dir }}"
      version: "{{ app_version }}"     # Tag, branche ou hash de commit
      force: yes                        # Écraser les modifications locales
      accept_hostkey: yes               # Accepter la clé SSH de GitHub
    become: yes
    become_user: "{{ app_user }}"
    register: git_result
    # git_result.changed = True si le code a été mis à jour

  - name: Afficher si le code a été mis à jour
    debug:
      msg: "Code mis à jour: {{ git_result.changed }}"

  # ── 3. Créer l'environnement virtuel Python ───────────────────────────
  - name: Créer le virtualenv Python
    command: "{{ python_bin }} -m venv {{ venv_dir }}"
    args:
      creates: "{{ venv_dir }}/bin/activate"   # Ne pas recréer s'il existe
    become: yes
    become_user: "{{ app_user }}"

  # ── 4. Installer les dépendances Python ──────────────────────────────
  # POURQUOI pip et pas "pip install"?
  # Le module pip d'Ansible gère le virtualenv, l'idempotence, les versions.
  - name: Installer les dépendances Python (requirements.txt)
    pip:
      requirements: "{{ app_dir }}/requirements.txt"
      virtualenv: "{{ venv_dir }}"
      state: present
    become: yes
    become_user: "{{ app_user }}"
    notify: Redémarrer l'application Flask
    # Si les dépendances changent -> redémarrer Flask automatiquement

  # ── 5. Créer le fichier .env de l'application ────────────────────────
  # POURQUOI un template et pas "copy"?
  # Le template Jinja2 permet d'injecter les variables Ansible (et secrets Vault)
  # dans le fichier .env. Chaque environnement a des valeurs différentes.
  - name: Créer le fichier .env de l'application
    template:
      src: .env.j2
      dest: "{{ app_dir }}/.env"
      owner: "{{ app_user }}"
      group: "{{ app_group }}"
      mode: '0600'               # Lisible UNIQUEMENT par l'utilisateur deploy
    notify: Redémarrer l'application Flask
    no_log: true                 # Ne pas afficher (contient des secrets)

  # ── 6. Initialiser/migrer la base de données ─────────────────────────
  - name: Initialiser la base de données (créer les tables)
    command: >
      {{ venv_dir }}/bin/python run.py
    args:
      chdir: "{{ app_dir }}"
    environment:
      FLASK_ENV: "{{ flask_env }}"
    become: yes
    become_user: "{{ app_user }}"
    run_once: true               # Exécuter UNE SEULE FOIS même sur plusieurs serveurs
    # run_once = éviter de migrer 2x si on a un cluster de serveurs Flask

  # ── 7. Créer le service systemd pour Flask/Gunicorn ──────────────────
  # POURQUOI systemd et pas "python run.py &" en arrière-plan?
  # systemd: redémarre automatiquement si l'app plante (Restart=always).
  # Démarre au boot. Gère les logs (journalctl). Interface standardisée.
  - name: Créer le service systemd Flask
    template:
      src: taskmanager.service.j2
      dest: /etc/systemd/system/taskmanager.service
      mode: '0644'
    notify:
      - Recharger systemd
      - Redémarrer l'application Flask
    # Deux handlers notifiés: d'abord recharger systemd, puis redémarrer l'app.
    # Ansible les exécute dans l'ordre où ils sont déclarés dans handlers/main.yml.

  # ── 8. Activer et démarrer le service ────────────────────────────────
  - name: Activer et démarrer le service TaskManager
    service:
      name: taskmanager
      state: started
      enabled: yes              # Démarrer au boot

  # ── 9. Vérifier que l'application répond ────────────────────────────
  - name: Attendre que Flask soit prêt (max 30s)
    uri:
      url: "http://localhost:{{ flask_port }}/health"
      method: GET
      status_code: 200
    register: health_result
    until: health_result.status == 200
    retries: 10         # Essayer 10 fois
    delay: 3            # Attendre 3 secondes entre chaque essai
    # until/retries/delay = boucle d'attente (polling)
    # Utile pour attendre qu'un service soit prêt après démarrage

  - name: Afficher le résultat du health check
    debug:
      msg: "Flask API opérationnelle: {{ health_result.json }}"

  nano roles/flask_app/templates/.env.j2

  Contenu:
  # .env — Généré par Ansible pour {{ inventory_hostname }}
  # Environnement: {{ flask_env }}
  # Version: {{ app_version }}
  # Ne pas modifier manuellement — sera écrasé au prochain déploiement Ansible

  FLASK_ENV={{ flask_env }}
  FLASK_DEBUG={{ flask_debug }}
  FLASK_APP=run.py
  SECRET_KEY={{ flask_secret_key }}
  APP_VERSION={{ app_version }}
  PORT={{ flask_port }}

  DATABASE_URL=postgresql://{{ postgres_user }}:{{ postgres_password }}@localhost:{{ postgres_port }}/{{ postgres_db }}
  REDIS_URL=redis://{% if redis_password %}:{{ redis_password }}@{% endif %}localhost:{{ redis_port }}/0

  POSTGRES_DB={{ postgres_db }}
  POSTGRES_USER={{ postgres_user }}
  POSTGRES_PASSWORD={{ postgres_password }}
  POSTGRES_HOST=localhost
  POSTGRES_PORT={{ postgres_port }}

  nano roles/flask_app/templates/taskmanager.service.j2

  Contenu:
  # taskmanager.service — Généré par Ansible
  # Géré par: systemctl {start|stop|restart|status} taskmanager

  [Unit]
  Description=TaskManager API (Flask + Gunicorn)
  Documentation=https://github.com/equipe/taskmanager
  After=network.target postgresql.service redis-server.service
  # After = démarrer seulement après ces services

  [Service]
  Type=notify
  User={{ app_user }}
  Group={{ app_group }}
  WorkingDirectory={{ app_dir }}

  # Charger les variables d'environnement depuis le fichier .env
  EnvironmentFile={{ app_dir }}/.env

  # Commande de démarrage: Gunicorn avec workers
  ExecStart={{ venv_dir }}/bin/gunicorn \
    --workers {{ gunicorn_workers }} \
    --timeout {{ gunicorn_timeout }} \
    --bind 127.0.0.1:{{ flask_port }} \
    --access-logfile /var/log/taskmanager/access.log \
    --error-logfile /var/log/taskmanager/error.log \
    --log-level {{ log_level | lower }} \
    --pid /run/taskmanager.pid \
    "run:app"
  # run:app = dans le fichier run.py, la variable "app" (instance Flask)

  # Commande pour recharger la config sans downtime
  ExecReload=/bin/kill -s HUP $MAINPID

  # Redémarrer automatiquement si l'app plante
  Restart=always
  RestartSec=5       # Attendre 5s avant de redémarrer

  # Limites de sécurité
  NoNewPrivileges=yes
  PrivateTmp=yes
  ProtectSystem=strict
  ReadWritePaths={{ app_dir }} /var/log/taskmanager /run

  # Limites de ressources
  LimitNOFILE=65536

  [Install]
  WantedBy=multi-user.target

  nano roles/flask_app/handlers/main.yml

  Contenu:
  ---
  - name: Recharger systemd
    systemd:
      daemon_reload: yes

  - name: Redémarrer l'application Flask
    service:
      name: taskmanager
      state: restarted

  - name: Recharger l'application Flask (graceful)
    service:
      name: taskmanager
      state: reloaded
    # reloaded = signal HUP -> Gunicorn fait un graceful restart
    # (les requêtes en cours finissent avant le redémarrage des workers)

  nano roles/flask_app/defaults/main.yml

  Contenu:
  ---
  app_user: deploy
  app_group: deploy
  app_dir: /opt/taskmanager
  app_repo: git@github.com:equipe/taskmanager.git
  app_version: main
  python_version: "3.11"
  python_bin: /usr/bin/python3
  venv_dir: /opt/taskmanager/venv
  flask_port: 5000
  flask_env: production
  flask_debug: "0"
  gunicorn_workers: 3
  gunicorn_timeout: 120
  log_level: WARNING

  ═══════════════════════════════════════════════════════════════════════════
  ROLE 5: nginx — Installer et configurer Nginx comme reverse proxy (Alice)
  ═══════════════════════════════════════════════════════════════════════════

  ansible-galaxy role init roles/nginx

  nano roles/nginx/tasks/main.yml

  Contenu:
  ---
  # ═══════════════════════════════════════════════════════════════════════
  # Role: nginx — Nginx comme reverse proxy devant Gunicorn/Flask
  # ═══════════════════════════════════════════════════════════════════════
  # Pourquoi Nginx devant Gunicorn?
  # -> Nginx gère les connexions HTTP de façon très efficace (asynchrone)
  # -> Nginx sert les fichiers statiques directement (sans passer par Python)
  # -> Nginx gère le SSL/TLS (certificats HTTPS)
  # -> Nginx protège contre certaines attaques (rate limiting, buffer overflow)
  # -> Gunicorn se concentre sur le Python pur
  #
  # Architecture:
  # Client -> Nginx (port 80/443) -> Gunicorn (port 5000, localhost) -> Flask

  - name: Installer Nginx
    apt:
      name: nginx
      state: present

  - name: Désactiver la configuration Nginx par défaut
    file:
      path: /etc/nginx/sites-enabled/default
      state: absent
    notify: Recharger Nginx

  - name: Créer la configuration Nginx pour TaskManager
    template:
      src: taskmanager.nginx.j2
      dest: /etc/nginx/sites-available/taskmanager
      mode: '0644'
    notify: Recharger Nginx

  - name: Activer la configuration Nginx TaskManager
    file:
      src: /etc/nginx/sites-available/taskmanager
      dest: /etc/nginx/sites-enabled/taskmanager
      state: link             # Créer un lien symbolique

  - name: Configurer nginx.conf global
    template:
      src: nginx.conf.j2
      dest: /etc/nginx/nginx.conf
      mode: '0644'
    notify: Recharger Nginx

  - name: Tester la configuration Nginx
    command: nginx -t
    changed_when: false
    register: nginx_test
    failed_when: nginx_test.rc != 0
    # Vérifier la syntaxe de la config AVANT de recharger
    # Si la config est invalide: arrêter le playbook (erreur explicite)
    # Évite: "nginx rechargé -> erreur -> site inaccessible"

  - name: Démarrer et activer Nginx
    service:
      name: nginx
      state: started
      enabled: yes

  # ── Certificat SSL avec Let's Encrypt (environnement production) ──────
  - name: Installer Certbot
    apt:
      name:
        - certbot
        - python3-certbot-nginx
      state: present
    when: enable_ssl | bool

  - name: Obtenir le certificat SSL Let's Encrypt
    command: >
      certbot certonly --nginx
      -d {{ domain }}
      --non-interactive
      --agree-tos
      --email admin@equipe.com
      --redirect
    args:
      creates: "/etc/letsencrypt/live/{{ domain }}/fullchain.pem"
    when: enable_ssl | bool
    notify: Recharger Nginx
    # creates = idempotent: ne pas renouveler si le certificat existe déjà

  - name: Configurer le renouvellement automatique du certificat SSL
    cron:
      name: "Renouveler SSL Let's Encrypt"
      minute: "0"
      hour: "3"
      weekday: "1"             # Tous les lundis à 3h
      job: "certbot renew --quiet --post-hook 'systemctl reload nginx'"
      user: root
    when: enable_ssl | bool

  nano roles/nginx/templates/taskmanager.nginx.j2

  Contenu:
  # taskmanager.nginx — Généré par Ansible
  # Configuration Nginx pour TaskManager API

  # ── Redirection HTTP -> HTTPS (production uniquement) ──────────────────
  {% if enable_ssl | bool %}
  server {
      listen 80;
      server_name {{ domain }};
      return 301 https://$host$request_uri;
  }
  {% endif %}

  # ── Serveur principal ─────────────────────────────────────────────────
  server {
      {% if enable_ssl | bool %}
      listen 443 ssl http2;
      {% else %}
      listen 80;
      {% endif %}
      server_name {{ domain }};

      {% if enable_ssl | bool %}
      ssl_certificate     {{ ssl_certificate }};
      ssl_certificate_key {{ ssl_certificate_key }};
      ssl_protocols       TLSv1.2 TLSv1.3;
      ssl_ciphers         ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512;
      ssl_prefer_server_ciphers on;
      ssl_session_cache   shared:SSL:10m;
      ssl_session_timeout 10m;

      add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
      {% endif %}

      # ── En-têtes de sécurité ──────────────────────────────────────────
      add_header X-Frame-Options "SAMEORIGIN" always;
      add_header X-Content-Type-Options "nosniff" always;
      add_header X-XSS-Protection "1; mode=block" always;
      add_header Referrer-Policy "strict-origin-when-cross-origin" always;

      # ── Logging ───────────────────────────────────────────────────────
      access_log /var/log/nginx/taskmanager/access.log combined;
      error_log  /var/log/nginx/taskmanager/error.log warn;

      # ── Rate limiting ─────────────────────────────────────────────────
      # Protège contre les abus d'API (10 requêtes/s par IP)
      limit_req zone=api burst=20 nodelay;

      # ── Proxy vers Gunicorn ───────────────────────────────────────────
      location / {
          proxy_pass         http://127.0.0.1:{{ flask_port }};
          proxy_set_header   Host $host;
          proxy_set_header   X-Real-IP $remote_addr;
          proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
          proxy_set_header   X-Forwarded-Proto $scheme;

          # Timeouts
          proxy_connect_timeout 60s;
          proxy_send_timeout    60s;
          proxy_read_timeout    60s;

          # Buffers (performance)
          proxy_buffering    on;
          proxy_buffer_size  4k;
          proxy_buffers      8 4k;
      }

      # ── Endpoint de santé (pas de log) ───────────────────────────────
      location /health {
          proxy_pass http://127.0.0.1:{{ flask_port }}/health;
          access_log off;    # Pas de log pour les health checks (bruyant)
      }

      # ── Limite de taille des requêtes ────────────────────────────────
      client_max_body_size 10M;
  }

  nano roles/nginx/templates/nginx.conf.j2

  Contenu:
  # nginx.conf global — Généré par Ansible
  user www-data;
  worker_processes {{ nginx_worker_processes }};
  pid /run/nginx.pid;

  events {
      worker_connections {{ nginx_worker_connections }};
      multi_accept on;
      use epoll;
  }

  http {
      # ── Performance ────────────────────────────────────────────────────
      sendfile on;
      tcp_nopush on;
      tcp_nodelay on;
      keepalive_timeout 65;
      types_hash_max_size 2048;
      server_tokens off;     # Ne pas afficher la version Nginx dans les en-têtes

      # ── Rate limiting global ──────────────────────────────────────────
      limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
      # Zone "api": 10MB pour stocker les compteurs par IP
      # rate=10r/s: 10 requêtes par seconde par IP (burst géré dans location)

      # ── MIME types ────────────────────────────────────────────────────
      include /etc/nginx/mime.types;
      default_type application/octet-stream;

      # ── Compression gzip ─────────────────────────────────────────────
      gzip on;
      gzip_vary on;
      gzip_min_length 1000;
      gzip_comp_level 6;
      gzip_types
          application/json
          application/javascript
          text/css
          text/plain
          text/xml;

      # ── Logs ─────────────────────────────────────────────────────────
      access_log /var/log/nginx/access.log;
      error_log  /var/log/nginx/error.log;

      # ── Inclure les sites activés ─────────────────────────────────────
      include /etc/nginx/conf.d/*.conf;
      include /etc/nginx/sites-enabled/*;
  }

  nano roles/nginx/handlers/main.yml

  Contenu:
  ---
  - name: Recharger Nginx
    service:
      name: nginx
      state: reloaded

  - name: Redémarrer Nginx
    service:
      name: nginx
      state: restarted

  nano roles/nginx/defaults/main.yml

  Contenu:
  ---
  nginx_port: 80
  nginx_ssl_port: 443
  nginx_worker_processes: auto
  nginx_worker_connections: 1024
  enable_ssl: false
  domain: localhost
  flask_port: 5000

  ═══════════════════════════════════════════════════════════════════════════
  ROLE 6: monitoring — Prometheus + Node Exporter + Grafana (Alice)
  ═══════════════════════════════════════════════════════════════════════════

  ansible-galaxy role init roles/monitoring

  nano roles/monitoring/tasks/main.yml

  Contenu:
  ---
  # POURQUOI LE MONITORING?
  # Sans monitoring: "l'API est lente" -> on ne sait pas pourquoi.
  # Avec Prometheus + Grafana: graphiques en temps réel (CPU, RAM, requêtes/s,
  # temps de réponse moyen, taux d'erreur, connexions PostgreSQL...).

  # ── Node Exporter: métriques système (CPU, RAM, disque) ───────────────
  - name: Créer l'utilisateur node_exporter
    user:
      name: node_exporter
      shell: /bin/false      # Pas de shell interactif (sécurité)
      system: yes
      create_home: no

  - name: Télécharger Node Exporter
    get_url:
      url: "https://github.com/prometheus/node_exporter/releases/download/v1.7.0/node_exporter-1.7.0.linux-amd64.tar.gz"
      dest: /tmp/node_exporter.tar.gz
      checksum: "sha256:a550cd5c05f7ca07882c73fcf5a51e9....."  # Vérifier l'intégrité

  - name: Extraire Node Exporter
    unarchive:
      src: /tmp/node_exporter.tar.gz
      dest: /usr/local/bin/
      remote_src: yes    # Le fichier est déjà sur le serveur distant
      extra_opts: ["--strip-components=1"]

  - name: Créer le service systemd Node Exporter
    copy:
      content: |
        [Unit]
        Description=Node Exporter
        After=network.target

        [Service]
        User=node_exporter
        ExecStart=/usr/local/bin/node_exporter \
          --web.listen-address=:{{ node_exporter_port }}
        Restart=always

        [Install]
        WantedBy=multi-user.target
      dest: /etc/systemd/system/node_exporter.service
    notify:
      - Recharger systemd monitoring
      - Redémarrer Node Exporter

  - name: Démarrer Node Exporter
    service:
      name: node_exporter
      state: started
      enabled: yes

  # ── Prometheus: collecte et stockage des métriques ────────────────────
  - name: Créer l'utilisateur Prometheus
    user:
      name: prometheus
      shell: /bin/false
      system: yes
      create_home: no

  - name: Créer les dossiers Prometheus
    file:
      path: "{{ item }}"
      state: directory
      owner: prometheus
      group: prometheus
    loop:
      - /etc/prometheus
      - /var/lib/prometheus

  - name: Télécharger Prometheus
    get_url:
      url: "https://github.com/prometheus/prometheus/releases/download/v2.49.0/prometheus-2.49.0.linux-amd64.tar.gz"
      dest: /tmp/prometheus.tar.gz

  - name: Extraire les binaires Prometheus
    unarchive:
      src: /tmp/prometheus.tar.gz
      dest: /tmp/
      remote_src: yes

  - name: Installer les binaires Prometheus
    copy:
      src: "/tmp/prometheus-2.49.0.linux-amd64/{{ item }}"
      dest: "/usr/local/bin/{{ item }}"
      mode: '0755'
      remote_src: yes
    loop:
      - prometheus
      - promtool

  - name: Configurer Prometheus
    template:
      src: prometheus.yml.j2
      dest: /etc/prometheus/prometheus.yml
      owner: prometheus
      group: prometheus
    notify: Redémarrer Prometheus

  - name: Créer le service systemd Prometheus
    copy:
      content: |
        [Unit]
        Description=Prometheus
        After=network.target

        [Service]
        User=prometheus
        ExecStart=/usr/local/bin/prometheus \
          --config.file=/etc/prometheus/prometheus.yml \
          --storage.tsdb.path=/var/lib/prometheus/ \
          --storage.tsdb.retention.time=30d \
          --web.listen-address=:{{ prometheus_port }}
        Restart=always

        [Install]
        WantedBy=multi-user.target
      dest: /etc/systemd/system/prometheus.service
    notify:
      - Recharger systemd monitoring
      - Redémarrer Prometheus

  - name: Démarrer Prometheus
    service:
      name: prometheus
      state: started
      enabled: yes
    when: monitoring_enabled | bool

  nano roles/monitoring/templates/prometheus.yml.j2

  Contenu:
  # prometheus.yml — Généré par Ansible
  global:
    scrape_interval: 15s
    evaluation_interval: 15s
    external_labels:
      environment: '{{ flask_env }}'
      app: 'taskmanager'

  scrape_configs:
    # Prometheus se scrape lui-même
    - job_name: 'prometheus'
      static_configs:
        - targets: ['localhost:{{ prometheus_port }}']

    # Métriques système
    - job_name: 'node'
      static_configs:
        - targets: ['localhost:{{ node_exporter_port }}']
          labels:
            instance: '{{ inventory_hostname }}'

    # Métriques Flask (flask-prometheus-exporter à ajouter dans requirements.txt)
    - job_name: 'flask_app'
      metrics_path: '/metrics'
      static_configs:
        - targets: ['localhost:{{ flask_port }}']

    # Métriques PostgreSQL (postgres_exporter à installer)
    - job_name: 'postgresql'
      static_configs:
        - targets: ['localhost:9187']

  nano roles/monitoring/handlers/main.yml

  Contenu:
  ---
  - name: Recharger systemd monitoring
    systemd:
      daemon_reload: yes

  - name: Redémarrer Node Exporter
    service:
      name: node_exporter
      state: restarted

  - name: Redémarrer Prometheus
    service:
      name: prometheus
      state: restarted


================================================================================
  TERRAFORM & ANSIBLE EN ÉQUIPE DE 3 DÉVELOPPEURS — APPLICATION FLASK
  CONTINUATION DU GUIDE — PARTIES 5 À 12
  Guide ultra-détaillé pour GRAND DÉBUTANT
================================================================================

================================================================================
PARTIE 5 — ANSIBLE: PLAYBOOKS PRINCIPAUX (ALICE)
================================================================================

  RAPPEL: OÙ EN SOMMES-NOUS?
  ────────────────────────────
  Alice a:
  [OK] Installé Terraform et Ansible
  [OK] Créé la structure du projet infrastructure/
  [OK] Écrit les fichiers Terraform (main.tf, variables.tf, outputs.tf)
  [OK] Exécuté terraform apply -> le serveur Ubuntu existe sur DigitalOcean (IP: 164.90.154.23)
  [OK] Créé les 6 roles Ansible (common, postgresql, redis, flask_app, nginx, monitoring)
  [OK] Créé les fichiers de variables et secrets (Vault)

  PROCHAINE ÉTAPE: Écrire les playbooks qui UTILISENT ces roles.
  Un playbook = fichier YAML qui dit: "sur ces serveurs, appliquer ces roles dans cet ordre"

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 5.1 — LE PLAYBOOK PRINCIPAL: site.yml
────────────────────────────────────────────────────────────────────────────────

  POURQUOI site.yml?
  ───────────────────
  site.yml = le "chef d'orchestre" qui coordonne TOUS les roles.
  Il répond à: "Comment configurer un serveur de ZÉRO en une seule commande?"
  C'est le playbook qu'Alice lance quand un nouveau serveur vient d'être créé.

  QUAND L'UTILISER?
  ──────────────────
  -> Première configuration d'un serveur neuf (après terraform apply)
  -> Reconstruction complète d'un serveur qui a été recréé
  -> Pour s'assurer que TOUT est à jour (idempotent: sûr de relancer)

  nano infrastructure/ansible/site.yml

  Contenu complet et ultra-commenté:

  ---
  # ═══════════════════════════════════════════════════════════════════════════
  # site.yml — Playbook principal: configure un serveur de ZÉRO à PRODUCTION
  # ═══════════════════════════════════════════════════════════════════════════
  #
  # Usage:
  #   ansible-playbook -i inventory/staging.ini site.yml
  #   ansible-playbook -i inventory/staging.ini site.yml --vault-password-file ~/.vault_pass
  #   ansible-playbook -i inventory/staging.ini site.yml --tags "flask_app,nginx"
  #   ansible-playbook -i inventory/staging.ini site.yml --check  (dry-run)
  #
  # Ce playbook:
  #   1. Collecte les "facts" (infos sur le serveur: RAM, OS, IP...)
  #   2. Configure la base du système (common)
  #   3. Installe et configure PostgreSQL
  #   4. Installe et configure Redis
  #   5. Déploie l'application Flask
  #   6. Configure Nginx comme reverse proxy
  #   7. Configure le monitoring (optionnel selon variables)
  # ═══════════════════════════════════════════════════════════════════════════

  # ── PLAY 1: Configuration de tous les serveurs ──────────────────────────
  # Un "play" = un ensemble de tâches appliquées à un groupe d'hôtes.
  # Ce premier play s'applique à tous les hôtes (groupe "all").
  - name: "PLAY 1 — Configuration système de base"
    hosts: all                    # S'applique à tous les hôtes de l'inventaire
    become: yes                   # Utiliser sudo (root) pour toutes les tâches
    gather_facts: yes             # Collecter les infos système (RAM, IP, OS...)
                                  # Ces infos sont disponibles comme variables:
                                  # ansible_memtotal_mb, ansible_distribution, etc.

    # Variables chargées pour ce play (en plus de group_vars/).
    # Les fichiers vault sont déchiffrés automatiquement si le mot de passe est fourni.
    vars_files:
      - vars/staging_secrets.yml  # Mots de passe chiffrés (postgres, flask secret key...)

    # pre_tasks = tâches exécutées AVANT les roles
    pre_tasks:
      - name: Afficher les informations du serveur
        debug:
          msg: |
            ══════════════════════════════════════════════════════
            Serveur:    {{ inventory_hostname }}
            IP:         {{ ansible_host }}
            OS:         {{ ansible_distribution }} {{ ansible_distribution_version }}
            RAM:        {{ ansible_memtotal_mb }} MB
            CPU:        {{ ansible_processor_cores }} cœurs
            Kernel:     {{ ansible_kernel }}
            ══════════════════════════════════════════════════════

      - name: Vérifier que le serveur est Ubuntu 22.04
        assert:
          that:
            - ansible_distribution == "Ubuntu"
            - ansible_distribution_major_version == "22"
          fail_msg: "Ce playbook nécessite Ubuntu 22.04. Trouvé: {{ ansible_distribution }} {{ ansible_distribution_version }}"
          success_msg: "[OK] Ubuntu 22.04 confirmé"
        # assert = vérification: si la condition est fausse, le playbook s'arrête.
        # Évite de configurer un mauvais OS et de casser un serveur.

      - name: Générer le fichier /etc/motd (message de connexion SSH)
        copy:
          content: |
            ████████╗ █████╗ ███████╗██╗  ██╗███╗   ███╗ █████╗ ███╗   ██╗
               ██╔══╝██╔══██╗██╔════╝██║ ██╔╝████╗ ████║██╔══██╗████╗  ██║
               ██║   ███████║███████╗█████╔╝ ██╔████╔██║███████║██╔██╗ ██║
               ██║   ██╔══██║╚════██║██╔═██╗ ██║╚██╔╝██║██╔══██║██║ ╚████║
               ██║   ██║  ██║███████║██║  ██╗██║ ╚═╝ ██║██║  ██║██║  ╚███║
               ╚═╝   ╚═╝  ╚═╝╚══════╝╚═╝  ╚═╝╚═╝     ╚═╝╚═╝  ╚═╝╚═╝   ╚══╝
            Serveur: {{ inventory_hostname }} | Env: {{ flask_env }}
            Géré par Ansible — NE PAS modifier manuellement!
            Toute modification sera écrasée au prochain déploiement.
          dest: /etc/motd
          mode: '0644'

    # Roles appliqués dans l'ORDRE défini ici:
    roles:
      - role: common         # Configuration système de base (timezone, paquets, UFW...)
        tags: [common, base] # Tags pour exécution sélective

      - role: postgresql     # Base de données PostgreSQL
        tags: [postgresql, db, database]
        # when: "'db' in group_names"  # Si DB est sur un serveur séparé en prod

      - role: redis          # Cache Redis
        tags: [redis, cache]

      - role: flask_app      # Application Flask (clone git, venv, service systemd)
        tags: [flask, app, deploy]

      - role: nginx          # Reverse proxy Nginx
        tags: [nginx, web]

      - role: monitoring     # Prometheus + Node Exporter (optionnel)
        tags: [monitoring, metrics]
        when: monitoring_enabled | bool  # Seulement si activé dans les variables

    # post_tasks = tâches exécutées APRÈS tous les roles
    post_tasks:
      - name: Vérification finale — Health check de l'API
        uri:
          url: "http://localhost/health"
          method: GET
          status_code: 200
          return_content: yes
        register: final_health
        retries: 5
        delay: 10
        until: final_health.status == 200

      - name: Afficher le résultat du déploiement
        debug:
          msg: |
            ══════════════════════════════════════════════════════
            [OK] DÉPLOIEMENT RÉUSSI!
            ══════════════════════════════════════════════════════
            URL:        http://{{ ansible_host }}/health
            Réponse:    {{ final_health.json }}
            Durée:      {{ ansible_date_time.iso8601 }}
            Version:    {{ app_version }}
            Environ.:   {{ flask_env }}
            ══════════════════════════════════════════════════════

      - name: Envoyer notification Slack (si webhook configuré)
        uri:
          url: "{{ slack_webhook_url }}"
          method: POST
          body_format: json
          body:
            text: "[OK] Déploiement *{{ app_version }}* sur *{{ inventory_hostname }}* réussi! Env: {{ flask_env }}"
        when: slack_webhook_url is defined and slack_webhook_url != ""
        ignore_errors: yes   # Ne pas faire échouer le playbook si Slack est en panne


  # ── PLAY 2: Vérification post-déploiement (depuis la machine locale) ────
  # Ce 2ème play s'exécute sur "localhost" (la machine d'Alice, pas le serveur).
  # Il vérifie que l'API est accessible depuis l'extérieur.
  - name: "PLAY 2 — Vérification externe depuis localhost"
    hosts: localhost
    gather_facts: no           # Pas besoin des facts du serveur distant ici

    tasks:
      - name: Vérifier que l'API répond depuis Internet
        uri:
          url: "http://{{ hostvars[groups['web'][0]]['ansible_host'] }}/health"
          method: GET
          status_code: 200
          timeout: 30
        register: external_check
        retries: 3
        delay: 5
        until: external_check.status == 200

      - name: Afficher la confirmation externe
        debug:
          msg: "[OK] API accessible depuis Internet: {{ external_check.json }}"

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 5.2 — LE PLAYBOOK DE DÉPLOIEMENT RAPIDE: deploy.yml
────────────────────────────────────────────────────────────────────────────────

  POURQUOI deploy.yml SÉPARÉ DE site.yml?
  ─────────────────────────────────────────
  site.yml = configuration COMPLÈTE (tout installer depuis zéro) -> 10-15 minutes.
  deploy.yml = déployer UNIQUEMENT le nouveau code -> 2-3 minutes.

  Quand Bob pousse une nouvelle feature, on ne veut pas réinstaller PostgreSQL!
  On veut juste: git pull + pip install (si nouvelles dépendances) + restart Flask.

  QUAND L'UTILISER?
  ──────────────────
  -> À chaque nouvelle version de l'application (nouveaux commits)
  -> Via GitHub Actions automatiquement après un merge dans main
  -> Manuellement par Alice pour une mise à jour urgente

  nano infrastructure/ansible/deploy.yml

  Contenu:

  ---
  # ═══════════════════════════════════════════════════════════════════════════
  # deploy.yml — Déploiement rapide: code seul (sans réinstaller les services)
  # ═══════════════════════════════════════════════════════════════════════════
  #
  # Usage:
  #   ansible-playbook -i inventory/staging.ini deploy.yml
  #   ansible-playbook -i inventory/staging.ini deploy.yml -e "app_version=v1.2.0"
  #   ansible-playbook -i inventory/staging.ini deploy.yml -e "app_version=develop"
  #
  # Ce playbook ne touche PAS à:
  #   - PostgreSQL (schema, données)
  #   - Redis (configuration)
  #   - Nginx (configuration)
  #   - Configuration système (timezone, paquets...)
  #
  # Il fait UNIQUEMENT:
  #   1. git pull (nouveau code)
  #   2. pip install -r requirements.txt (nouvelles dépendances si besoin)
  #   3. Migration de base de données (flask db upgrade si applicable)
  #   4. Mettre à jour le fichier .env (si les variables ont changé)
  #   5. Redémarrer Gunicorn gracefully (sans downtime)
  #   6. Vérifier que l'API répond
  # ═══════════════════════════════════════════════════════════════════════════

  - name: "DEPLOY — Déploiement rapide de l'application Flask"
    hosts: web                  # Seulement les serveurs web (pas les serveurs DB séparés)
    become: yes
    gather_facts: yes
    serial: 1                   # Déployer UN serveur à la fois (important si cluster)
                                # serial: "50%" déploierait 50% des serveurs en parallèle
                                # Ici: staging a 1 serveur donc serial n'a pas d'effet visible

    vars_files:
      - vars/staging_secrets.yml

    vars:
      # La version peut être surchargée via -e "app_version=v1.2.0"
      # Par défaut: utiliser la valeur de group_vars
      deploy_timestamp: "{{ ansible_date_time.epoch }}"

    pre_tasks:
      - name: Afficher la version déployée
        debug:
          msg: "Déploiement de la version: {{ app_version }} sur {{ inventory_hostname }}"

      - name: Vérifier que le service Flask tourne avant déploiement
        service:
          name: taskmanager
          state: started
        register: service_check
        ignore_errors: yes

      # ZERO DOWNTIME PATTERN: mettre un fichier "maintenance" pendant le déploiement
      # Nginx retourne 503 avec une belle page pendant les 2-3 secondes de restart.
      # Optionnel pour un serveur seul, ESSENTIEL pour un cluster (load balancer).
      - name: Activer le mode maintenance Nginx
        copy:
          content: |
            <!DOCTYPE html>
            <html>
            <head><title>Maintenance</title></head>
            <body>
              <h1>[OUTIL] Maintenance en cours</h1>
              <p>L'API TaskManager est en cours de mise à jour. Revenez dans quelques secondes.</p>
            </body>
            </html>
          dest: /var/www/maintenance.html
          mode: '0644'
        when: enable_maintenance_page | default(false) | bool

    tasks:
      - name: Récupérer le commit actuel (avant déploiement)
        command: git -C "{{ app_dir }}" rev-parse --short HEAD
        register: git_before
        become_user: "{{ app_user }}"
        changed_when: false

      - name: Récupérer les derniers changements depuis Git
        git:
          repo: "{{ app_repo }}"
          dest: "{{ app_dir }}"
          version: "{{ app_version }}"
          force: yes
          accept_hostkey: yes
        become_user: "{{ app_user }}"
        register: git_result

      - name: Récupérer le nouveau commit (après déploiement)
        command: git -C "{{ app_dir }}" rev-parse --short HEAD
        register: git_after
        become_user: "{{ app_user }}"
        changed_when: false

      - name: Afficher le diff des commits
        debug:
          msg: "Code: {{ git_before.stdout }} -> {{ git_after.stdout }} (changed: {{ git_result.changed }})"

      - name: Mettre à jour les dépendances Python si requirements.txt a changé
        pip:
          requirements: "{{ app_dir }}/requirements.txt"
          virtualenv: "{{ venv_dir }}"
          state: present
        become_user: "{{ app_user }}"
        # Note: pip n'installe que les NOUVEAUX paquets (idempotent).
        # Si requirements.txt n'a pas changé, cette tâche est très rapide.

      - name: Mettre à jour le fichier .env
        template:
          src: roles/flask_app/templates/.env.j2
          dest: "{{ app_dir }}/.env"
          owner: "{{ app_user }}"
          group: "{{ app_group }}"
          mode: '0600'
        register: env_update
        no_log: true

      - name: Appliquer les migrations de base de données
        command: "{{ venv_dir }}/bin/flask db upgrade"
        args:
          chdir: "{{ app_dir }}"
        environment:
          FLASK_APP: run.py
          FLASK_ENV: "{{ flask_env }}"
        become_user: "{{ app_user }}"
        run_once: true              # Une seule fois même si plusieurs serveurs Flask
        when: run_db_migrations | default(true) | bool
        # POURQUOI run_once? Si vous avez 3 serveurs Flask, la migration ne doit
        # s'exécuter qu'une fois (pas 3 fois simultanément sur la même DB!).

      - name: Redémarrer Flask gracefully (signal HUP -> zero downtime)
        service:
          name: taskmanager
          state: reloaded
        # "reloaded" = envoie SIGHUP à Gunicorn.
        # Gunicorn démarre de nouveaux workers avec le nouveau code,
        # attend que les anciens workers finissent leurs requêtes,
        # puis les arrête.
        # Les utilisateurs ne voient AUCUNE interruption.
        notify: Vérifier la santé après redémarrage

      - name: Attendre que Flask soit opérationnel
        uri:
          url: "http://localhost:{{ flask_port }}/health"
          method: GET
          status_code: 200
        register: health_after_deploy
        until: health_after_deploy.status == 200
        retries: 15
        delay: 2

    post_tasks:
      - name: Désactiver le mode maintenance
        file:
          path: /var/www/maintenance.html
          state: absent

      - name: Enregistrer le déploiement dans le fichier de log
        lineinfile:
          path: /var/log/taskmanager/deployments.log
          line: "{{ deploy_timestamp }} | version={{ app_version }} | commit={{ git_after.stdout }} | user={{ ansible_user }} | status=SUCCESS"
          create: yes
          mode: '0644'

      - name: Résumé du déploiement
        debug:
          msg: |
            ══════════════════════════════════════════════════════
            [OK] DÉPLOIEMENT TERMINÉ!
            Serveur:  {{ inventory_hostname }}
            Version:  {{ app_version }}
            Commit:   {{ git_before.stdout }} -> {{ git_after.stdout }}
            Santé:    {{ health_after_deploy.json.status }}
            ══════════════════════════════════════════════════════

    handlers:
      - name: Vérifier la santé après redémarrage
        uri:
          url: "http://localhost:{{ flask_port }}/health"
          status_code: 200
        retries: 5
        delay: 3
        until: result.status == 200
        register: result

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 5.3 — LE PLAYBOOK DE ROLLBACK: rollback.yml
────────────────────────────────────────────────────────────────────────────────

  POURQUOI UN PLAYBOOK DE ROLLBACK?
  ───────────────────────────────────
  Un déploiement peut mal se passer:
  - Nouveau code avec un bug critique non détecté en staging
  - Migration de DB qui casse des données
  - Incompatibilité imprévue

  Sans rollback: vous devez redeployer manuellement le code précédent -> 10 minutes.
  Avec rollback: ansible-playbook rollback.yml -> 2 minutes.

  QUAND L'UTILISER?
  ──────────────────
  -> Immédiatement après un déploiement qui a cassé quelque chose
  -> Quand les métriques de monitoring montrent une dégradation après déploiement
  -> Quand l'équipe décide de revenir en arrière

  nano infrastructure/ansible/rollback.yml

  Contenu:

  ---
  # ═══════════════════════════════════════════════════════════════════════════
  # rollback.yml — Revenir à la version précédente de l'application
  # ═══════════════════════════════════════════════════════════════════════════
  #
  # Usage:
  #   ansible-playbook -i inventory/production.ini rollback.yml
  #   ansible-playbook -i inventory/production.ini rollback.yml -e "rollback_to=v1.0.0"
  #
  # Ce playbook:
  #   1. Détermine la version précédente (depuis le log des déploiements)
  #   2. Revient à cette version dans Git
  #   3. Réinstalle les dépendances de l'ancienne version
  #   4. Redémarre l'application
  #   5. Vérifie que tout fonctionne
  # ═══════════════════════════════════════════════════════════════════════════

  - name: "ROLLBACK — Revenir à la version précédente"
    hosts: web
    become: yes
    gather_facts: no

    vars_files:
      - vars/staging_secrets.yml

    pre_tasks:
      - name: Lire le log des déploiements
        slurp:
          src: /var/log/taskmanager/deployments.log
        register: deploy_log
        ignore_errors: yes

      - name: Afficher les 5 derniers déploiements
        debug:
          msg: "{{ (deploy_log.content | b64decode).split('\n') | select | list | reverse | list[:5] }}"
        when: deploy_log.content is defined

      - name: Récupérer la version actuelle
        command: git -C "{{ app_dir }}" rev-parse --short HEAD
        register: current_commit
        become_user: "{{ app_user }}"
        changed_when: false

      - name: Afficher la version actuelle
        debug:
          msg: "Version actuelle: {{ current_commit.stdout }}"

      # Si rollback_to n'est pas fourni: revenir au commit précédent
      - name: Déterminer le commit de rollback (si non spécifié)
        command: git -C "{{ app_dir }}" rev-parse --short HEAD~1
        register: previous_commit
        become_user: "{{ app_user }}"
        changed_when: false
        when: rollback_to is not defined

      - name: Définir la version de rollback
        set_fact:
          rollback_version: "{{ rollback_to | default(previous_commit.stdout) }}"

      - name: Confirmer le rollback
        pause:
          prompt: |
            [ATTENTION]  ATTENTION: Vous allez rollback {{ inventory_hostname }}
            De: {{ current_commit.stdout }}
            Vers: {{ rollback_version }}
            Tapez 'oui' pour confirmer (Ctrl+C pour annuler):
        register: confirmation
        when: not (skip_confirmation | default(false) | bool)

      - name: Vérifier la confirmation
        fail:
          msg: "Rollback annulé par l'utilisateur."
        when:
          - not (skip_confirmation | default(false) | bool)
          - confirmation.user_input != "oui"

    tasks:
      - name: Rollback du code vers {{ rollback_version }}
        git:
          repo: "{{ app_repo }}"
          dest: "{{ app_dir }}"
          version: "{{ rollback_version }}"
          force: yes
        become_user: "{{ app_user }}"

      - name: Restaurer les dépendances Python de l'ancienne version
        pip:
          requirements: "{{ app_dir }}/requirements.txt"
          virtualenv: "{{ venv_dir }}"
          state: present
        become_user: "{{ app_user }}"

      - name: Mettre à jour le .env pour l'ancienne version
        template:
          src: roles/flask_app/templates/.env.j2
          dest: "{{ app_dir }}/.env"
          owner: "{{ app_user }}"
          group: "{{ app_group }}"
          mode: '0600'
        vars:
          app_version: "{{ rollback_version }}"
        no_log: true

      - name: Redémarrer l'application avec l'ancien code
        service:
          name: taskmanager
          state: restarted

      - name: Vérifier que l'application est de nouveau opérationnelle
        uri:
          url: "http://localhost:{{ flask_port }}/health"
          status_code: 200
        register: rollback_health
        retries: 10
        delay: 3
        until: rollback_health.status == 200

    post_tasks:
      - name: Enregistrer le rollback dans les logs
        lineinfile:
          path: /var/log/taskmanager/deployments.log
          line: "{{ ansible_date_time.epoch }} | ROLLBACK | from={{ current_commit.stdout }} | to={{ rollback_version }} | status=SUCCESS"
          create: yes

      - name: Résumé du rollback
        debug:
          msg: |
            ══════════════════════════════════════════════════════
            [OK] ROLLBACK RÉUSSI!
            Serveur:  {{ inventory_hostname }}
            Retour:   {{ current_commit.stdout }} -> {{ rollback_version }}
            Santé:    {{ rollback_health.json.status }}
            ══════════════════════════════════════════════════════

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 5.4 — PLAYBOOK DE MAINTENANCE: maintenance.yml
────────────────────────────────────────────────────────────────────────────────

  POURQUOI UN PLAYBOOK DE MAINTENANCE?
  ──────────────────────────────────────
  Certaines tâches doivent être faites périodiquement:
  - Rotation des logs (éviter que les logs saturent le disque)
  - Nettoyage des fichiers temporaires
  - Sauvegarde de la base de données
  - Vérification de l'intégrité du système

  nano infrastructure/ansible/maintenance.yml

  Contenu:

  ---
  # ═══════════════════════════════════════════════════════════════════════════
  # maintenance.yml — Tâches de maintenance périodiques
  # ═══════════════════════════════════════════════════════════════════════════
  #
  # Usage:
  #   # Planifié via cron ou GitHub Actions (hebdomadaire):
  #   ansible-playbook -i inventory/production.ini maintenance.yml
  #   ansible-playbook -i inventory/production.ini maintenance.yml --tags backup
  #   ansible-playbook -i inventory/production.ini maintenance.yml --tags cleanup
  # ═══════════════════════════════════════════════════════════════════════════

  - name: "MAINTENANCE — Tâches périodiques"
    hosts: all
    become: yes
    gather_facts: yes

    vars_files:
      - vars/staging_secrets.yml

    tasks:
      # ── BACKUP ──────────────────────────────────────────────────────────
      - name: Créer le dossier de sauvegarde
        file:
          path: /var/backups/taskmanager
          state: directory
          mode: '0750'
        tags: [backup]

      - name: Sauvegarder la base de données PostgreSQL
        shell: |
          PGPASSWORD="{{ postgres_password }}" pg_dump \
            -h localhost \
            -U {{ postgres_user }} \
            -d {{ postgres_db }} \
            --format=custom \
            --no-acl \
            --no-owner \
            -f /var/backups/taskmanager/db_{{ ansible_date_time.date }}_{{ ansible_date_time.hour }}h.dump
        become_user: postgres
        no_log: true
        tags: [backup]
        # --format=custom: format compressé et restaurable par pg_restore
        # --no-acl et --no-owner: permissions transférables entre serveurs

      - name: Compresser les anciennes sauvegardes (> 7 jours)
        find:
          paths: /var/backups/taskmanager
          patterns: "*.dump"
          age: "7d"
          age_stamp: mtime
        register: old_dumps
        tags: [backup]

      - name: Supprimer les sauvegardes > 30 jours
        find:
          paths: /var/backups/taskmanager
          patterns: "*.dump,*.dump.gz"
          age: "30d"
          age_stamp: mtime
        register: very_old_dumps
        tags: [backup]

      - name: Effacer les très anciennes sauvegardes
        file:
          path: "{{ item.path }}"
          state: absent
        loop: "{{ very_old_dumps.files }}"
        tags: [backup]

      # ── CLEANUP ──────────────────────────────────────────────────────────
      - name: Nettoyer les logs de l'application > 30 jours
        find:
          paths: /var/log/taskmanager
          patterns: "*.log.*"
          age: "30d"
        register: old_logs
        tags: [cleanup]

      - name: Supprimer les vieux logs
        file:
          path: "{{ item.path }}"
          state: absent
        loop: "{{ old_logs.files }}"
        tags: [cleanup]

      - name: Nettoyer le cache pip (libère de l'espace disque)
        command: "{{ venv_dir }}/bin/pip cache purge"
        become_user: "{{ app_user }}"
        changed_when: false
        tags: [cleanup]

      - name: Nettoyer les packages apt inutilisés
        apt:
          autoremove: yes
          purge: yes
        tags: [cleanup]

      # ── HEALTH CHECKS ─────────────────────────────────────────────────────
      - name: Vérifier l'espace disque
        shell: df -h / | awk 'NR==2 {print $5}' | tr -d '%'
        register: disk_usage
        changed_when: false
        tags: [health]

      - name: Alerter si l'espace disque > 80%
        debug:
          msg: "[ATTENTION] ALERTE: Espace disque à {{ disk_usage.stdout }}% sur {{ inventory_hostname }}"
        when: disk_usage.stdout | int > 80
        tags: [health]

      - name: Vérifier l'état de tous les services
        service:
          name: "{{ item }}"
          state: started
        loop:
          - taskmanager
          - nginx
          - "postgresql@15-main"
          - redis-server
        tags: [health]

      # ── MISES À JOUR DE SÉCURITÉ ──────────────────────────────────────────
      - name: Appliquer les mises à jour de sécurité Ubuntu
        apt:
          upgrade: safe
          update_cache: yes
          cache_valid_time: 0    # Forcer le refresh de la liste des paquets
        register: security_updates
        tags: [security, updates]

      - name: Afficher les mises à jour appliquées
        debug:
          msg: "Mises à jour de sécurité: {{ security_updates.stdout_lines | default([]) }}"
        tags: [security, updates]

      - name: Vérifier si un redémarrage est nécessaire
        stat:
          path: /var/run/reboot-required
        register: reboot_required
        tags: [security, updates]

      - name: Alerter si redémarrage nécessaire
        debug:
          msg: "[ATTENTION] Un redémarrage est nécessaire sur {{ inventory_hostname }} après les mises à jour de sécurité."
        when: reboot_required.stat.exists
        tags: [security, updates]

    post_tasks:
      - name: Générer le rapport de maintenance
        debug:
          msg: |
            ═══════════════════════════════════════════════════════
            [OK] MAINTENANCE TERMINÉE — {{ ansible_date_time.iso8601 }}
            Serveur:    {{ inventory_hostname }}
            Disque:     {{ disk_usage.stdout }}% utilisé
            Redémarr.:  {{ 'REQUIS' if reboot_required.stat.exists else 'Non requis' }}
            ═══════════════════════════════════════════════════════

================================================================================
PARTIE 6 — ALICE EXÉCUTE ANSIBLE: PREMIER DÉPLOIEMENT COMPLET
================================================================================

  CONTEXTE:
  ──────────
  Alice a terminé terraform apply -> le serveur Ubuntu 22.04 existe sur DigitalOcean
  IP publique: 164.90.154.23
  Alice a écrit tous les roles et playbooks.
  Il est temps de CONFIGURER le serveur et DÉPLOYER l'application.

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 6.1 — TESTER LA CONNEXION SSH VERS LE SERVEUR
────────────────────────────────────────────────────────────────────────────────

  POURQUOI TESTER AVANT ANSIBLE?
  ────────────────────────────────
  Ansible utilise SSH pour se connecter aux serveurs.
  Si SSH ne fonctionne pas, Ansible échouera immédiatement.
  Mieux vaut tester SSH d'abord pour diagnostiquer les problèmes.

  ALICE DANS SON TERMINAL:
  ─────────────────────────
  cd infrastructure/ansible/

  # Test 1: Connexion SSH directe
  ssh -i ~/.ssh/id_ed25519_github ubuntu@164.90.154.23

  CE QUE VOUS DEVEZ VOIR:
  ────────────────────────
  The authenticity of host '164.90.154.23 (164.90.154.23)' can't be established.
  ED25519 key fingerprint is SHA256:AbCdEfGh...
  Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
  Warning: Permanently added '164.90.154.23' (ED25519) to the list of known hosts.

  ████████╗ █████╗ ███████╗██╗  ██╗███╗   ███╗...
  Serveur: taskmanager-staging | Env: staging
  (Ceci est /etc/motd, généré par Terraform user_data)

  Welcome to Ubuntu 22.04.3 LTS (GNU/Linux 5.15.0-91-generic x86_64)

  ubuntu@taskmanager-staging:~$

  # Vérifier que Python est installé (requis par Ansible):
  python3 --version
  # -> Python 3.10.12

  # Quitter SSH:
  exit

  # Test 2: Ansible ping (teste SSH + Python)
  ansible -i inventory/staging.ini all -m ping

  CE QUE VOUS DEVEZ VOIR:
  ────────────────────────
  taskmanager-staging | SUCCESS => {
      "ansible_facts": {
          "discovered_interpreter_python": "/usr/bin/python3"
      },
      "changed": false,
      "ping": "pong"
  }

  # [OK] Ansible peut se connecter au serveur!

  SI VOUS VOYEZ "UNREACHABLE":
  ─────────────────────────────
  taskmanager-staging | UNREACHABLE! => {
      "changed": false,
      "msg": "Failed to connect to the host via ssh: Permission denied (publickey).",
      "unreachable": true
  }

  SOLUTIONS:
  -> Vérifier que la clé SSH a été ajoutée à DigitalOcean pendant terraform
  -> Vérifier que ssh-add ~/.ssh/id_ed25519_github a été fait
  -> Vérifier que ansible.cfg pointe vers la bonne clé SSH
  -> Tester: ssh -v ubuntu@164.90.154.23 (verbose -> voit l'échec d'authentification)

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 6.2 — COLLECTER LES "FACTS" ANSIBLE (INFORMATIONS DU SERVEUR)
────────────────────────────────────────────────────────────────────────────────

  POURQUOI LES FACTS?
  ────────────────────
  Avant d'exécuter les tâches, Ansible collecte automatiquement des informations
  sur le serveur (facts): RAM, CPU, version d'OS, IP, etc.
  Ces informations sont disponibles comme variables dans tous les templates et tâches.

  VOIR TOUS LES FACTS:
  ─────────────────────
  ansible -i inventory/staging.ini taskmanager-staging -m setup

  CE QUE VOUS VOYEZ (extrait):
  ─────────────────────────────
  taskmanager-staging | SUCCESS => {
      "ansible_facts": {
          "ansible_architecture": "x86_64",
          "ansible_distribution": "Ubuntu",
          "ansible_distribution_release": "jammy",
          "ansible_distribution_version": "22.04",
          "ansible_memtotal_mb": 3928,
          "ansible_processor_cores": 2,
          "ansible_processor_vcpus": 2,
          "ansible_kernel": "5.15.0-91-generic",
          "ansible_hostname": "taskmanager-staging",
          "ansible_eth0": {
              "ipv4": {
                  "address": "164.90.154.23",
                  "netmask": "255.255.240.0"
              }
          },
          "ansible_eth1": {
              "ipv4": {
                  "address": "10.0.0.5"  <- IP privée du VPC
              }
          },
          "ansible_devices": {
              "sda": {...}  <- Volume DigitalOcean attaché par Terraform
          }
          ...
      }
  }

  FILTRER LES FACTS (pour voir seulement ce qui m'intéresse):
  ─────────────────────────────────────────────────────────────
  ansible -i inventory/staging.ini all -m setup -a "filter=ansible_mem*"
  # -> ansible_memfree_mb: 2847
  # -> ansible_memtotal_mb: 3928

  ansible -i inventory/staging.ini all -m setup -a "filter=ansible_eth*"
  # -> IPs réseau

  ansible -i inventory/staging.ini all -m setup -a "filter=ansible_distribution*"
  # -> Nom et version de l'OS

  # Ces variables sont utilisées dans nos templates:
  # postgresql.conf.j2: shared_buffers = {{ (ansible_memtotal_mb * 0.25) | int }}MB
  # redis.conf.j2: bind 127.0.0.1 {{ ansible_eth0.ipv4.address }}

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 6.3 — EXÉCUTER EN MODE DRY-RUN (--check)
────────────────────────────────────────────────────────────────────────────────

  POURQUOI --check?
  ──────────────────
  --check = "Montre ce que tu ferais SANS rien faire réellement."
  Comme terraform plan mais pour Ansible.
  Idéal pour vérifier un playbook avant de l'exécuter sur un serveur de production.

  QUAND L'UTILISER?
  ──────────────────
  -> Toujours avant d'exécuter site.yml sur un serveur de production
  -> Avant d'appliquer une modification de configuration sensible
  -> Pour diagnostiquer ce qui sera changé après une modification de template

  ALICE LANCE LE DRY-RUN:
  ─────────────────────────
  ansible-playbook -i inventory/staging.ini site.yml \
    --vault-password-file ~/.vault_pass_staging \
    --check \
    --diff

  # --check = ne rien exécuter
  # --diff  = afficher les différences dans les fichiers modifiés (comme git diff)

  CE QUE VOUS VOYEZ:
  ───────────────────
  PLAY [PLAY 1 — Configuration système de base] *****

  TASK [Gathering Facts] ****
  ok: [taskmanager-staging]

  TASK [Afficher les informations du serveur] ****
  ok: [taskmanager-staging] => {
      "msg": "Serveur: taskmanager-staging | IP: 164.90.154.23 | OS: Ubuntu 22.04 | RAM: 3928 MB"
  }

  TASK [common : Configurer le timezone] ****
  changed: [taskmanager-staging]    <- Cette tâche CHANGERAIT quelque chose

  TASK [common : Mettre à jour le cache apt] ****
  ok: [taskmanager-staging]         <- Cette tâche ne changerait rien

  TASK [postgresql : Configurer postgresql.conf (performances)] ****
  --- before
  +++ after: roles/postgresql/templates/postgresql.conf.j2
  @@ -1,3 +1,25 @@
  +# postgresql.conf — Généré par Ansible
  +listen_addresses = 'localhost,164.90.154.23'
  +shared_buffers = 982MB            <- 25% de 3928MB
  +effective_cache_size = 2946MB
  ...
  changed: [taskmanager-staging]

  PLAY RECAP ****
  taskmanager-staging         : ok=8   changed=12   unreachable=0    failed=0

  # 12 tâches changeraient quelque chose. 8 sont déjà à l'état voulu.
  # Aucun échec prévu. Alice peut exécuter en confiance.

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 6.4 — EXÉCUTION RÉELLE DU PLAYBOOK SITE.YML
────────────────────────────────────────────────────────────────────────────────

  ALICE LANCE LE DÉPLOIEMENT COMPLET:
  ─────────────────────────────────────
  cd infrastructure/ansible/

  ansible-playbook -i inventory/staging.ini site.yml \
    --vault-password-file ~/.vault_pass_staging \
    --diff

  # Si vous voulez voir plus de détails: ajouter -v (verbose), -vv, ou -vvv
  # -v   = détails des tâches
  # -vv  = + contenus des fichiers
  # -vvv = + connexions SSH

  CE QUE VOUS VOYEZ EN TEMPS RÉEL:
  ──────────────────────────────────

  PLAY [PLAY 1 — Configuration système de base] *********************************

  TASK [Gathering Facts] *********************************************************
  ok: [taskmanager-staging]

  TASK [Afficher les informations du serveur] ************************************
  ok: [taskmanager-staging] => {
      "msg": "══════════════════════════════════════════════════════\nServeur:    taskmanager-staging\nIP:         164.90.154.23\nOS:         Ubuntu 22.04\nRAM:        3928 MB\nCPU:        2 cœurs\n..."
  }

  TASK [Vérifier que le serveur est Ubuntu 22.04] ********************************
  ok: [taskmanager-staging] => {
      "changed": false,
      "msg": "[OK] Ubuntu 22.04 confirmé"
  }

  TASK [common : Configurer le timezone] *****************************************
  changed: [taskmanager-staging]

  TASK [common : Mettre à jour le cache apt] *************************************
  changed: [taskmanager-staging]

  TASK [common : Appliquer les mises à jour de sécurité] *************************
  changed: [taskmanager-staging]

  TASK [common : Installer les paquets communs] ***********************************
  changed: [taskmanager-staging]

  TASK [common : Créer la configuration fail2ban SSH] ****************************
  changed: [taskmanager-staging]

  RUNNING HANDLER [common : Redémarrer fail2ban] **********************************
  changed: [taskmanager-staging]

  TASK [common : Créer l'utilisateur applicatif] **********************************
  changed: [taskmanager-staging]

  TASK [common : Créer le dossier de l'application] ******************************
  changed: [taskmanager-staging]

  TASK [common : Créer les dossiers de logs] *************************************
  changed: [taskmanager-staging] => (item=/var/log/taskmanager)
  changed: [taskmanager-staging] => (item=/var/log/nginx/taskmanager)
  changed: [taskmanager-staging] => (item=/opt/taskmanager/logs)

  TASK [postgresql : Ajouter la clé GPG PostgreSQL] ******************************
  changed: [taskmanager-staging]

  TASK [postgresql : Ajouter le dépôt PostgreSQL] ********************************
  changed: [taskmanager-staging]

  TASK [postgresql : Installer PostgreSQL 15] ************************************
  changed: [taskmanager-staging]

  TASK [postgresql : Démarrer et activer PostgreSQL] *****************************
  changed: [taskmanager-staging]

  TASK [postgresql : Créer la base de données TaskManager] ***********************
  changed: [taskmanager-staging]

  TASK [postgresql : Créer l'utilisateur PostgreSQL] *****************************
  changed: [taskmanager-staging]

  TASK [postgresql : Configurer postgresql.conf] **********************************
  --- before
  +++ after
  @@ -0,0 +1,40 @@
  +# postgresql.conf — Généré par Ansible
  +listen_addresses = 'localhost,164.90.154.23'
  +port = 5432
  +shared_buffers = 982MB
  +effective_cache_size = 2946MB
  ...
  changed: [taskmanager-staging]

  RUNNING HANDLER [postgresql : Redémarrer PostgreSQL] ***************************
  changed: [taskmanager-staging]

  TASK [redis : Installer Redis] *************************************************
  changed: [taskmanager-staging]

  TASK [redis : Configurer Redis] ************************************************
  changed: [taskmanager-staging]

  RUNNING HANDLER [redis : Redémarrer Redis] *************************************
  changed: [taskmanager-staging]

  TASK [redis : Vérifier que Redis répond] ***************************************
  ok: [taskmanager-staging]

  TASK [redis : Afficher le résultat du ping Redis] ******************************
  ok: [taskmanager-staging] => {"msg": "Redis répond: PONG"}

  TASK [flask_app : Installer Python 3.11] ***************************************
  changed: [taskmanager-staging]

  TASK [flask_app : Cloner/mettre à jour le code] ********************************
  changed: [taskmanager-staging]

  TASK [flask_app : Créer le virtualenv Python] **********************************
  changed: [taskmanager-staging]

  TASK [flask_app : Installer les dépendances Python] ****************************
  changed: [taskmanager-staging]

  TASK [flask_app : Créer le fichier .env de l'application] **********************
  changed: [taskmanager-staging]

  TASK [flask_app : Initialiser la base de données] ******************************
  changed: [taskmanager-staging]

  TASK [flask_app : Créer le service systemd Flask] ******************************
  changed: [taskmanager-staging]

  RUNNING HANDLER [flask_app : Recharger systemd] ********************************
  changed: [taskmanager-staging]

  RUNNING HANDLER [flask_app : Redémarrer l'application Flask] *******************
  changed: [taskmanager-staging]

  TASK [flask_app : Activer et démarrer le service TaskManager] ******************
  ok: [taskmanager-staging]

  TASK [flask_app : Attendre que Flask soit prêt (max 30s)] *********************
  ok: [taskmanager-staging]

  TASK [flask_app : Afficher le résultat du health check] ************************
  ok: [taskmanager-staging] => {"msg": "Flask API opérationnelle: {'status': 'healthy', 'database': 'ok'}"}

  TASK [nginx : Installer Nginx] *************************************************
  changed: [taskmanager-staging]

  TASK [nginx : Configurer nginx.conf global] ************************************
  changed: [taskmanager-staging]

  TASK [nginx : Créer la configuration Nginx pour TaskManager] *******************
  changed: [taskmanager-staging]

  TASK [nginx : Activer la configuration Nginx TaskManager] **********************
  changed: [taskmanager-staging]

  TASK [nginx : Tester la configuration Nginx] ***********************************
  ok: [taskmanager-staging]

  RUNNING HANDLER [nginx : Recharger Nginx] **************************************
  changed: [taskmanager-staging]

  TASK [nginx : Démarrer et activer Nginx] ***************************************
  ok: [taskmanager-staging]

  TASK [Vérification finale — Health check de l'API] *****************************
  ok: [taskmanager-staging]

  TASK [Afficher le résultat du déploiement] *************************************
  ok: [taskmanager-staging] => {
      "msg": "══════════════════════════════════════════════════════\n[OK] DÉPLOIEMENT RÉUSSI!\n══════════════════════════════════════════════════════\nURL:        http://164.90.154.23/health\nRéponse:    {'status': 'healthy', 'database': 'ok'}\nVersion:    develop\nEnviron.:   staging\n══════════════════════════════════════════════════════"
  }

  PLAY RECAP *********************************************************************
  taskmanager-staging    : ok=47   changed=31   unreachable=0    failed=0    skipped=2

  # ok=47     = 47 tâches ont réussi
  # changed=31 = 31 tâches ont effectivement modifié quelque chose
  # failed=0   = aucun échec -> déploiement réussi!

  TESTER L'API DEPUIS LA MACHINE D'ALICE:
  ────────────────────────────────────────
  curl http://164.90.154.23/health
  # -> {"database": "ok", "status": "healthy", "timestamp": "2024-01-15T10:30:00Z"}  [OK]

  curl -X POST http://164.90.154.23/tasks \
    -H "Content-Type: application/json" \
    -d '{"title": "Première tâche en staging!", "priority": "high"}'
  # -> {"id": 1, "title": "Première tâche en staging!", "done": false, "priority": "high", ...}  [OK]

  curl http://164.90.154.23/tasks
  # -> {"tasks": [{"id": 1, ...}], "total": 1, "page": 1, ...}  [OK]

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 6.5 — RELANCER LE PLAYBOOK: L'IDEMPOTENCE EN ACTION
────────────────────────────────────────────────────────────────────────────────

  POURQUOI RELANCER?
  ───────────────────
  Relancer site.yml 10 fois = même résultat.
  C'est l'IDEMPOTENCE: la pierre angulaire d'Ansible.
  Cela signifie que vous pouvez relancer sans crainte à tout moment.

  Alice relance le playbook une 2ème fois sans rien changer:

  ansible-playbook -i inventory/staging.ini site.yml \
    --vault-password-file ~/.vault_pass_staging

  CE QUE VOUS VOYEZ:
  ───────────────────
  PLAY RECAP *****
  taskmanager-staging    : ok=47   changed=0    unreachable=0    failed=0    skipped=2

  # changed=0 <- Rien n'a changé! Ansible a vérifié chaque tâche et confirmé
  # que tout est déjà dans l'état attendu.

  # EXPLICATION par type de module:
  # -> apt(state=present): paquet déjà installé -> OK, changed=false
  # -> service(state=started): service déjà démarré -> OK, changed=false
  # -> template: fichier identique -> OK, changed=false
  # -> git: même commit -> OK, changed=false

  # [ATTENTION] NOTE IMPORTANTE:
  # Certaines tâches NE SONT PAS idempotentes par nature:
  # - shell/command: Ansible les considère toujours comme "changed"
  # Solution: utiliser "changed_when: false" ou "creates: /chemin/fichier"

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 6.6 — EXÉCUTION AVEC TAGS: CIBLER DES PARTIES DU PLAYBOOK
────────────────────────────────────────────────────────────────────────────────

  POURQUOI LES TAGS?
  ───────────────────
  site.yml prend 10-15 minutes pour tout. Si Alice veut juste reconfigurer Nginx,
  elle ne veut pas attendre la réinstallation de PostgreSQL.
  Les tags permettent d'exécuter SEULEMENT certains roles ou tâches.

  VOIR TOUS LES TAGS DISPONIBLES:
  ─────────────────────────────────
  ansible-playbook -i inventory/staging.ini site.yml --list-tags

  CE QUE VOUS VOYEZ:
  ───────────────────
  playbook: site.yml

    play #1 (all): PLAY 1 — Configuration système de base
      TASK TAGS: [base, cache, common, database, db, deploy, flask, metrics,
                  monitoring, nginx, postgresql, redis, web]

  EXEMPLES D'UTILISATION:
  ─────────────────────────
  # Reconfigurer seulement Nginx (ex: après changement de config):
  ansible-playbook -i inventory/staging.ini site.yml \
    --tags nginx \
    --vault-password-file ~/.vault_pass_staging

  # Redéployer seulement l'application Flask:
  ansible-playbook -i inventory/staging.ini site.yml \
    --tags flask \
    --vault-password-file ~/.vault_pass_staging

  # Reconfigurer Nginx ET Flask (ex: après une mise à jour de l'API):
  ansible-playbook -i inventory/staging.ini site.yml \
    --tags "flask,nginx" \
    --vault-password-file ~/.vault_pass_staging

  # Tout SAUF le monitoring (plus rapide pour les tests):
  ansible-playbook -i inventory/staging.ini site.yml \
    --skip-tags monitoring \
    --vault-password-file ~/.vault_pass_staging

  TEMPS D'EXÉCUTION COMPARÉS:
  ─────────────────────────────
  site.yml complet:        ~12 minutes (première fois), ~3 minutes (idempotent)
  --tags nginx:            ~45 secondes
  --tags flask:            ~2 minutes (clone git + pip install)
  deploy.yml (code seul):  ~90 secondes

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 6.7 — LIMITER L'EXÉCUTION À CERTAINS HÔTES
────────────────────────────────────────────────────────────────────────────────

  POURQUOI --limit?
  ──────────────────
  L'inventaire peut avoir 10 serveurs. Parfois on veut cibler UN seul.
  --limit = "exécuter seulement sur ces hôtes-là"

  # Exécuter seulement sur taskmanager-staging:
  ansible-playbook -i inventory/production.ini site.yml \
    --limit taskmanager-staging

  # Exécuter sur plusieurs serveurs spécifiques:
  ansible-playbook -i inventory/production.ini site.yml \
    --limit "web1,web2"

  # Exécuter sur tous SAUF un serveur:
  ansible-playbook -i inventory/production.ini site.yml \
    --limit "web:!web3"   # Tous les serveurs du groupe web sauf web3

  # Exécuter sur un seul hôte par son IP (si le nom n'est pas dans l'inventaire):
  ansible-playbook site.yml -i 164.90.154.23,    # La virgule finale est requise!

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 6.8 — COMMANDES ANSIBLE AD-HOC (SANS PLAYBOOK)
────────────────────────────────────────────────────────────────────────────────

  POURQUOI LES COMMANDES AD-HOC?
  ────────────────────────────────
  Parfois vous n'avez pas besoin d'un playbook pour une action simple.
  Les commandes ad-hoc exécutent un module Ansible directement en ligne de commande.

  FORMAT: ansible [inventaire] [hôtes] -m [module] -a "[arguments]"

  EXEMPLES PRATIQUES:
  ────────────────────

  # Vérifier que tous les serveurs sont joignables:
  ansible -i inventory/staging.ini all -m ping

  # Voir l'espace disque sur tous les serveurs:
  ansible -i inventory/staging.ini all -m shell -a "df -h /"

  # Voir l'utilisation RAM:
  ansible -i inventory/staging.ini all -m shell -a "free -h"

  # Voir les processus Flask (Gunicorn) en cours:
  ansible -i inventory/staging.ini web -m shell -a "systemctl status taskmanager"

  # Redémarrer le service Flask sur tous les serveurs web:
  ansible -i inventory/staging.ini web -m service -a "name=taskmanager state=restarted" --become

  # Voir les dernières lignes du log Flask:
  ansible -i inventory/staging.ini web -m shell -a "tail -50 /var/log/taskmanager/error.log"

  # Copier un fichier de votre machine vers tous les serveurs:
  ansible -i inventory/staging.ini all -m copy \
    -a "src=/tmp/patch.py dest=/opt/taskmanager/patch.py owner=deploy mode=0644"

  # Exécuter une commande SQL sur PostgreSQL:
  ansible -i inventory/staging.ini db -m shell \
    -a "psql -U taskmanager -d taskmanager -c 'SELECT COUNT(*) FROM tasks;'" \
    --become --become-user postgres

  # Voir la version de Python utilisée:
  ansible -i inventory/staging.ini all -m shell -a "/opt/taskmanager/venv/bin/python --version"

  # Ajouter une clé SSH autorisée à l'utilisateur deploy:
  ansible -i inventory/staging.ini all -m authorized_key \
    -a "user=deploy key='ssh-ed25519 AAAA... bob@equipe.com' state=present"

  # Vérifier les logs système en temps réel:
  ansible -i inventory/staging.ini all -m shell -a "journalctl -u taskmanager -n 20 --no-pager"

================================================================================
PARTIE 7 — SCÉNARIOS COMPLETS: ALICE, BOB ET CLAIRE AU QUOTIDIEN
================================================================================

  CONTEXTE:
  ──────────
  L'infrastructure est en place. L'application tourne en staging.
  Voici les scénarios typiques de chaque membre de l'équipe.

════════════════════════════════════════════════════════════════════════════════
SCÉNARIO 1 — ALICE CRÉE L'ENVIRONNEMENT DE PRODUCTION
Contexte: Le staging fonctionne. L'heure de déployer en production est venue.
════════════════════════════════════════════════════════════════════════════════

  PLAN D'ALICE:
  ──────────────
  1. Créer l'infrastructure de production (Terraform)
  2. Configurer des secrets plus forts pour la prod (Ansible Vault)
  3. Configurer le serveur de production (Ansible + site.yml)
  4. Déployer la v1.0.0 en production

  ── ÉTAPE A: Créer la configuration Terraform de production ─────────────────

  cd infrastructure/terraform/environments/
  cp -r staging production
  cd production/

  # Adapter terraform.tfvars pour la production:
  nano terraform.tfvars

  Contenu:
  do_token             = "dop_v1_VOTRE_TOKEN_PROD_ICI"
  region               = "fra1"
  droplet_size         = "s-4vcpu-8gb"   # Plus puissant qu'en staging
  environment          = "production"
  project_name         = "taskmanager"
  domain               = "equipe.com"
  enable_backups       = true            # Sauvegardes activées en prod!
  data_volume_size_gb  = 50              # Plus d'espace pour les données prod

  # Adapter main.tf pour la production:
  nano main.tf

  # Changer les noms des ressources:
  # "taskmanager-staging" -> "taskmanager-production"
  # "taskmanager-staging-vpc" -> "taskmanager-production-vpc"
  # "taskmanager-staging-fw" -> "taskmanager-production-fw"

  # Changer l'enregistrement DNS:
  resource "digitalocean_record" "production_api" {
    domain = "equipe.com"
    type   = "A"
    name   = "api"                  # api.equipe.com (sans "-staging")
    value  = digitalocean_droplet.taskmanager_production.ipv4_address
    ttl    = 3600                   # 1 heure (plus stable qu'en staging)
  }

  # Changer outputs.tf:
  nano outputs.tf
  # Renommer staging_ip -> production_ip, staging_url -> production_url, etc.

  # Initialiser et déployer:
  terraform init
  terraform plan   # Alice vérifie ATTENTIVEMENT le plan de production

  CE QUE VOUS VOYEZ (important à vérifier):
  ──────────────────────────────────────────
  Plan: 7 to add, 0 to change, 0 to destroy.
  # 7 nouvelles ressources -> UNIQUEMENT de la création, rien de destruction [OK]
  # Si "destroy" apparaît -> STOP et comprendre POURQUOI avant d'appliquer

  # ALICE LIT CHAQUE LIGNE DU PLAN avant de taper "yes":
  terraform apply

  # Après création:
  Outputs:
  production_ip  = "165.22.80.45"
  production_url = "https://api.equipe.com"
  ssh_command    = "ssh ubuntu@165.22.80.45"

  ── ÉTAPE B: Créer les secrets de production plus forts ─────────────────────

  cd ../../ansible/

  # Mot de passe vault DIFFÉRENT du staging (isolation des secrets):
  ansible-vault create vars/prod_secrets.yml --vault-id prod@prompt

  # Contenu à saisir dans l'éditeur:
  ---
  flask_secret_key: "prod-ultra-secret-64-chars-minimum-openssl-rand-hex-64-HERE"
  postgres_password: "Pr0d-P@ssw0rd-Ultra-C0mpl3x-2024!"
  redis_password: "Pr0d-R3d1s-P@ss-2024!"
  do_api_token: "dop_v1_TOKEN_PRODUCTION"
  slack_webhook_url: "https://hooks.slack.com/services/PROD/SLACK/URL"

  # Stocker le mot de passe prod de façon sécurisée:
  echo "mot-de-passe-vault-prod-tres-secret" > ~/.vault_pass_prod
  chmod 600 ~/.vault_pass_prod

  ── ÉTAPE C: Créer l'inventaire de production ────────────────────────────────

  nano inventory/production.ini

  Contenu:
  # Inventaire PRODUCTION
  # [ATTENTION] Ce fichier est versionné dans Git mais PAS les IPs réelles
  # -> Les IPs réelles sont dans terraform.tfvars (non commité)
  # -> En CI/CD: récupérer avec: terraform output -raw production_ip

  [web]
  taskmanager-prod ansible_host=165.22.80.45

  [db]
  taskmanager-prod ansible_host=165.22.80.45

  [production:children]
  web
  db

  [all:vars]
  ansible_user=ubuntu
  ansible_python_interpreter=/usr/bin/python3

  [web:vars]
  app_port=5000
  nginx_port=80

  ── ÉTAPE D: Créer le fichier de variables de production ────────────────────

  # group_vars/production.yml (déjà créé dans la Partie 4):
  # Vérifier que le contenu est correct:
  cat group_vars/production.yml
  # flask_env: production
  # enable_ssl: true
  # gunicorn_workers: 4
  # monitoring_enabled: true
  # ...

  ── ÉTAPE E: Tester la connectivité ─────────────────────────────────────────

  ansible -i inventory/production.ini all \
    --vault-password-file ~/.vault_pass_prod \
    -m ping

  CE QUE VOUS VOYEZ:
  ───────────────────
  taskmanager-prod | SUCCESS => {"ping": "pong"}

  ── ÉTAPE F: Déploiement complet en production ───────────────────────────────

  # Dry-run OBLIGATOIRE avant prod:
  ansible-playbook -i inventory/production.ini site.yml \
    --vault-password-file ~/.vault_pass_prod \
    --check --diff

  # Alice lit TOUT le output du check avant de continuer.
  # Si tout semble correct:

  ansible-playbook -i inventory/production.ini site.yml \
    --vault-password-file ~/.vault_pass_prod

  # Durée: ~15 minutes pour une première installation complète.

  ── ÉTAPE G: Déployer la version spécifique v1.0.0 ──────────────────────────

  ansible-playbook -i inventory/production.ini deploy.yml \
    --vault-password-file ~/.vault_pass_prod \
    -e "app_version=v1.0.0"

  # -e "app_version=v1.0.0" = surcharger la variable app_version
  # Le playbook fait: git checkout v1.0.0 sur le serveur

  ── ÉTAPE H: Vérification finale ─────────────────────────────────────────────

  curl https://api.equipe.com/health
  # -> {"status": "healthy", "database": "ok"}

  curl -X POST https://api.equipe.com/tasks \
    -H "Content-Type: application/json" \
    -d '{"title": "Première tâche en production!", "priority": "high"}'
  # -> {"id": 1, "title": "Première tâche en production!", ...}

════════════════════════════════════════════════════════════════════════════════
SCÉNARIO 2 — BOB DÉPLOIE SA NOUVELLE FEATURE EN STAGING
Contexte: Bob a terminé feature/5-filtrage-dates sur Git. Sa PR est mergée.
          Il veut voir sa feature fonctionner sur le serveur de staging.
════════════════════════════════════════════════════════════════════════════════

  CONTEXTE TECHNIQUE:
  ────────────────────
  Bob a mergé ses modifications dans develop.
  La branche develop contient maintenant le code avec les filtres de dates.
  Il veut déployer ce code sur le serveur de staging.

  BOB DANS SON TERMINAL (Ubuntu):
  ─────────────────────────────────

  ── ÉTAPE A: Bob n'a PAS besoin de toucher à Terraform ──────────────────────
  # Le serveur de staging EXISTE DÉJÀ (Alice l'a créé).
  # Bob déploie SEULEMENT du nouveau code -> Ansible deploy.yml suffit.

  cd ~/taskmanager/infrastructure/ansible/

  ── ÉTAPE B: Vérifier que le serveur de staging est joignable ───────────────

  ansible -i inventory/staging.ini all -m ping
  # -> taskmanager-staging | SUCCESS => {"ping": "pong"}  [OK]

  ── ÉTAPE C: Voir ce que la commande deploy va faire ────────────────────────

  ansible-playbook -i inventory/staging.ini deploy.yml \
    --vault-password-file ~/.vault_pass_staging \
    --check --diff

  CE QUE VOUS VOYEZ:
  ───────────────────
  TASK [Récupérer les derniers changements depuis Git] ***
  --- before: sha1:a3f8c1d
  +++ after:  sha1:b7d2e4f   <- Nouveau commit (feature de Bob)
  changed: [taskmanager-staging]

  TASK [Mettre à jour les dépendances Python] ***
  ok: [taskmanager-staging]   <- requirements.txt n'a pas changé

  TASK [Mettre à jour le fichier .env] ***
  ok: [taskmanager-staging]   <- Variables d'environnement inchangées

  PLAY RECAP ***
  taskmanager-staging    : ok=5   changed=2   unreachable=0    failed=0

  # Seulement 2 changements: git pull + redémarrage Flask. Parfait!

  ── ÉTAPE D: Déploiement réel ────────────────────────────────────────────────

  ansible-playbook -i inventory/staging.ini deploy.yml \
    --vault-password-file ~/.vault_pass_staging \
    -e "app_version=develop"

  CE QUE VOUS VOYEZ:
  ───────────────────
  TASK [Afficher la version déployée] ***
  ok: [taskmanager-staging] => {"msg": "Déploiement de la version: develop sur taskmanager-staging"}

  TASK [Récupérer le commit actuel (avant déploiement)] ***
  ok: [taskmanager-staging] => {"stdout": "a3f8c1d"}

  TASK [Récupérer les derniers changements depuis Git] ***
  changed: [taskmanager-staging]

  TASK [Afficher le diff des commits] ***
  ok: [taskmanager-staging] => {"msg": "Code: a3f8c1d -> b7d2e4f (changed: True)"}

  TASK [Mettre à jour les dépendances Python] ***
  ok: [taskmanager-staging]

  TASK [Mettre à jour le fichier .env] ***
  ok: [taskmanager-staging]

  TASK [Appliquer les migrations de base de données] ***
  ok: [taskmanager-staging]

  TASK [Redémarrer Flask gracefully (signal HUP)] ***
  changed: [taskmanager-staging]

  TASK [Attendre que Flask soit opérationnel] ***
  ok: [taskmanager-staging]

  TASK [Résumé du déploiement] ***
  ok: [taskmanager-staging] => {
      "msg": "[OK] DÉPLOIEMENT TERMINÉ!\nVersion: develop\nCommit: a3f8c1d -> b7d2e4f\nSanté: healthy"
  }

  PLAY RECAP ***
  taskmanager-staging    : ok=11   changed=2   unreachable=0    failed=0

  # Total: 90 secondes environ. [OK]

  ── ÉTAPE E: Bob teste sa feature ────────────────────────────────────────────

  # Tester le filtrage par dates (nouvelle feature de Bob):
  curl "http://164.90.154.23/tasks?from=2024-01-01&to=2024-12-31"
  # -> {"tasks": [...], "total": 5, ...}  [OK]

  curl "http://164.90.154.23/tasks?sort=title&order=asc"
  # -> {"tasks": [...en ordre alphabétique...]}  [OK]

  # Tester le cas d'erreur:
  curl "http://164.90.154.23/tasks?from=2024-13-99"
  # -> {"error": "Invalid 'from' date format. Use YYYY-MM-DD"}, 400  [OK]

  # Bob est satisfait. Sa feature fonctionne en staging!
  # Il peut ouvrir sa PR vers develop (si ce n'est pas déjà fait).

════════════════════════════════════════════════════════════════════════════════
SCÉNARIO 3 — BOB CRÉE UN SERVEUR DE TEST PERSONNEL TEMPORAIRE
Contexte: Bob veut tester une modification risquée sans toucher au staging partagé.
          Il crée son propre serveur éphémère, teste, puis le détruit.
════════════════════════════════════════════════════════════════════════════════

  POURQUOI CE SCÉNARIO EST IMPORTANT?
  ─────────────────────────────────────
  Bob teste une migration de base de données qui change la structure des tables.
  Si ça casse en staging, toute l'équipe est bloquée.
  Avec Terraform, Bob peut créer son PROPRE serveur en 3 minutes.

  BOB DANS SON TERMINAL:
  ─────────────────────────

  ── ÉTAPE A: Créer un environnement Terraform temporaire ────────────────────

  cd ~/taskmanager/infrastructure/terraform/environments/
  cp -r staging bob-migration-test
  cd bob-migration-test/

  # Adapter pour un serveur temporaire moins cher:
  nano terraform.tfvars

  Contenu:
  do_token             = "dop_v1_TOKEN_BOB"
  region               = "fra1"
  droplet_size         = "s-1vcpu-2gb"   # Petit serveur de test (moins cher ~12$/mois)
  environment          = "test"
  project_name         = "taskmanager-bob-test"
  domain               = "equipe.com"
  enable_backups       = false
  data_volume_size_gb  = 10

  # Adapter main.tf pour éviter les conflits de noms avec staging/prod:
  # Remplacer "taskmanager-staging" par "taskmanager-bob-test" partout
  sed -i 's/taskmanager-staging/taskmanager-bob-test/g' main.tf

  # Supprimer l'enregistrement DNS (pas besoin pour un test temporaire):
  # Commenter ou supprimer le bloc digitalocean_record

  ── ÉTAPE B: Créer le serveur ────────────────────────────────────────────────

  terraform init
  terraform apply -auto-approve    # -auto-approve = pas de confirmation manuelle

  CE QUE VOUS VOYEZ:
  ───────────────────
  Apply complete! Resources: 5 added, 0 changed, 0 destroyed.

  Outputs:
  staging_ip   = "164.90.200.99"
  ssh_command  = "ssh ubuntu@164.90.200.99"

  # Durée: ~2 minutes. Bob a son propre serveur!

  ── ÉTAPE C: Créer un inventaire temporaire pour ce serveur ─────────────────

  cd ~/taskmanager/infrastructure/ansible/

  # Créer un inventaire one-shot:
  echo "[web]
  bob-test ansible_host=164.90.200.99

  [db]
  bob-test ansible_host=164.90.200.99

  [all:vars]
  ansible_user=ubuntu" > /tmp/bob_test_inventory.ini

  ── ÉTAPE D: Configurer le serveur ────────────────────────────────────────────

  ansible-playbook -i /tmp/bob_test_inventory.ini site.yml \
    --vault-password-file ~/.vault_pass_staging \
    -e "flask_env=test app_version=feature/ma-migration"

  ── ÉTAPE E: Bob teste sa migration ──────────────────────────────────────────

  # Bob se connecte à son serveur:
  ssh ubuntu@164.90.200.99

  # Active l'environnement virtuel:
  cd /opt/taskmanager
  source venv/bin/activate

  # Lance la migration de test:
  flask db upgrade
  # -> [bob voit si ça fonctionne ou non]

  # Teste les routes avec la nouvelle structure:
  curl http://localhost/tasks
  # -> [bob voit si l'API répond correctement]

  exit

  ── ÉTAPE F: Tester des scénarios qui casseraient le staging ─────────────────

  # Bob peut faire TOUT ce qu'il veut sur CE serveur:
  # - Corrompre la base de données
  # - Tester des migrations incorrectes
  # - Casser intentionnellement l'application
  # Rien de tout cela n'affecte le staging partagé.

  ── ÉTAPE G: Quand Bob a fini -> DÉTRUIRE le serveur ─────────────────────────

  cd ~/taskmanager/infrastructure/terraform/environments/bob-migration-test/

  terraform destroy -auto-approve

  CE QUE VOUS VOYEZ:
  ───────────────────
  digitalocean_record.bob_test: Destroying...
  digitalocean_droplet.taskmanager_bob_test: Destroying...
  digitalocean_droplet.taskmanager_bob_test: Still destroying... [30s elapsed]
  digitalocean_droplet.taskmanager_bob_test: Destruction complete after 48s
  digitalocean_volume.bob_test_data: Destroying...
  digitalocean_volume.bob_test_data: Destruction complete after 3s

  Destroy complete! Resources: 5 destroyed.

  # Le serveur est supprimé. DigitalOcean ne facture plus rien.
  # Durée du test: 3 heures -> coût: 3h * 0.018$/h = ~0.05$. Négligeable!

  # Nettoyer le dossier local:
  cd ~/taskmanager/infrastructure/terraform/environments/
  rm -rf bob-migration-test/

════════════════════════════════════════════════════════════════════════════════
SCÉNARIO 4 — CLAIRE VALIDE LE DÉPLOIEMENT DE STAGING
Contexte: Claire est responsable qualité. Elle vérifie que le staging est correct
          avant de valider le déploiement en production.
════════════════════════════════════════════════════════════════════════════════

  CLAIRE DANS SON TERMINAL (Windows WSL2):
  ──────────────────────────────────────────

  ── ÉTAPE A: Claire vérifie l'état de l'infrastructure ──────────────────────

  cd ~/taskmanager/infrastructure/terraform/environments/staging/

  # Voir l'état actuel de l'infrastructure:
  terraform show

  CE QUE VOUS VOYEZ:
  ───────────────────
  # digitalocean_droplet.taskmanager_staging:
  resource "digitalocean_droplet" "taskmanager_staging" {
      backups              = false
      id                   = "987654"
      image                = "ubuntu-22-04-x64"
      ipv4_address         = "164.90.154.23"
      name                 = "taskmanager-staging"
      region               = "fra1"
      size                 = "s-2vcpu-4gb"
      status               = "active"   <- Le serveur est actif [OK]
  }

  # Vérifier que le state est synchronisé avec la réalité:
  terraform plan

  CE QUE VOUS VOYEZ (idéal):
  ───────────────────────────
  No changes. Your infrastructure matches the configuration.

  # Parfait: ce que Terraform a créé correspond exactement au code .tf [OK]

  ── ÉTAPE B: Claire vérifie les services Ansible ─────────────────────────────

  cd ~/taskmanager/infrastructure/ansible/

  # Vérifier l'état de tous les services sur staging:
  ansible -i inventory/staging.ini all \
    -m service_facts   # Récupère le statut de TOUS les services systemd

  # Plus utile: vérifier seulement les services de l'application:
  ansible -i inventory/staging.ini web \
    -m shell \
    -a "systemctl is-active taskmanager nginx postgresql@15-main redis-server"

  CE QUE VOUS VOYEZ:
  ───────────────────
  taskmanager-staging | CHANGED | rc=0 >>
  active
  active
  active
  active

  # Les 4 services sont actifs [OK]

  ── ÉTAPE C: Claire exécute les tests Ansible (molecule) ─────────────────────

  # POURQUOI Molecule?
  # Molecule est un framework de test pour les roles Ansible.
  # Il permet de tester qu'un role fonctionne correctement sur un serveur propre.

  # Installation:
  pip install molecule molecule-docker

  # Tester le role nginx:
  cd roles/nginx/
  molecule test

  # molecule test:
  # 1. Crée un container Docker Ubuntu propre
  # 2. Applique le role
  # 3. Exécute les tests de vérification
  # 4. Détruit le container

  CE QUE VOUS VOYEZ:
  ───────────────────
  INFO     Running default > dependency
  INFO     Running default > lint
  INFO     Running default > cleanup
  INFO     Running default > destroy
  INFO     Running default > create (crée un container Docker Ubuntu)
  INFO     Running default > prepare
  INFO     Running default > converge (applique le role)

  TASK [nginx : Installer Nginx] *
  changed: [instance]

  TASK [nginx : Tester la configuration Nginx] *
  ok: [instance]

  INFO     Running default > idempotency (relance le role pour vérifier l'idempotence)
  INFO     Running default > verify (exécute les tests)

  TASK [Verify Nginx is running] *
  ok: [instance]

  TASK [Verify port 80 is listening] *
  ok: [instance]

  INFO     Running default > cleanup
  INFO     Running default > destroy (détruit le container)

  INFO     Verifier completed successfully.

  ── ÉTAPE D: Claire crée des tests de vérification pour le role nginx ────────

  nano roles/nginx/molecule/default/verify.yml

  Contenu:
  ---
  # Tests de vérification du role nginx
  - name: Verify Nginx deployment
    hosts: all
    gather_facts: false

    tasks:
      - name: Vérifier que Nginx est installé
        package:
          name: nginx
          state: present
        check_mode: yes   # Ne pas modifier, seulement vérifier
        register: nginx_check
        failed_when: nginx_check.changed

      - name: Vérifier que Nginx tourne
        service:
          name: nginx
          state: started
          enabled: yes
        check_mode: yes
        register: nginx_running
        failed_when: nginx_running.changed

      - name: Vérifier que le port 80 répond
        wait_for:
          port: 80
          host: localhost
          timeout: 10

      - name: Vérifier la réponse HTTP
        uri:
          url: "http://localhost/health"
          status_code: 200

      - name: Vérifier que la config Nginx est valide
        command: nginx -t
        changed_when: false

      - name: Vérifier les headers de sécurité
        uri:
          url: "http://localhost/"
          status_code: [200, 404]
          return_content: no
        register: headers_check
        failed_when: >
          'X-Frame-Options' not in headers_check.headers or
          'X-Content-Type-Options' not in headers_check.headers

  ── ÉTAPE E: Claire valide les performances ──────────────────────────────────

  # Claire installe k6 pour faire un test de charge simple:
  # k6 est un outil de load testing moderne

  # Sur Ubuntu (WSL2):
  sudo gpg -k
  sudo gpg --no-default-keyring \
    --keyring /usr/share/keyrings/k6-archive-keyring.gpg \
    --keyserver hkp://keyserver.ubuntu.com:80 \
    --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
  echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] \
    https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
  sudo apt-get update && sudo apt-get install k6

  # Créer un script de test de charge:
  cat > /tmp/loadtest_staging.js << 'EOF'
  import http from 'k6/http';
  import { check, sleep } from 'k6';

  export let options = {
    vus: 10,          // 10 utilisateurs virtuels simultanés
    duration: '30s',  // Durée du test: 30 secondes
  };

  export default function() {
    // Test GET /tasks
    let res = http.get('http://164.90.154.23/tasks');
    check(res, {
      'status is 200': (r) => r.status === 200,
      'response time < 500ms': (r) => r.timings.duration < 500,
    });

    // Test POST /tasks
    let payload = JSON.stringify({
      title: `Tâche test ${Date.now()}`,
      priority: 'medium'
    });
    let headers = {'Content-Type': 'application/json'};
    let postRes = http.post('http://164.90.154.23/tasks', payload, {headers: headers});
    check(postRes, {
      'create status is 201': (r) => r.status === 201,
      'create time < 1000ms': (r) => r.timings.duration < 1000,
    });

    sleep(1);  // Attendre 1 seconde entre chaque itération
  }
  EOF

  k6 run /tmp/loadtest_staging.js

  CE QUE VOUS VOYEZ:
  ───────────────────
            /\      |‾‾|  /‾‾/  /‾/
       /\  /  \     |  |_/  /  / /
      /  \/    \    |      |  /  ‾‾\
     /          \   |  |‾\  \ | (_) |
    / __________ \  |__|  \__\ \___/  (k6)

    execution: local
    script: /tmp/loadtest_staging.js
    output: -

    scenarios: (100.00%) 1 scenario, 10 max VUs, 1m0s max duration
             * default: 10 looping VUs for 30s

  running (0m30.0s), 00/10 VUs, 265 complete and 0 interrupted iterations
  default   [ 100% ] 10 VUs  30s

       [OK] status is 200
       [OK] response time < 500ms
       [OK] create status is 201
       [OK] create time < 1000ms

       checks.........................: 100.00% [OK] 530 [X] 0
       data_received..................: 248 kB  8.3 kB/s
       data_sent......................: 51 kB   1.7 kB/s
       http_req_duration..............: avg=89ms min=45ms med=78ms max=312ms p(90)=145ms p(95)=189ms
       http_reqs......................: 530     17.66/req
       iterations.....................: 265     8.83/s

  # [OK] 100% de checks passés! Temps de réponse moyen: 89ms.
  # Claire valide: l'application est prête pour la production.

════════════════════════════════════════════════════════════════════════════════
SCÉNARIO 5 — ALICE GÈRE UNE PANNE SERVEUR ET RECONSTRUIT
Contexte: Le serveur de staging a été corrompu. Alice doit tout reconstruire.
════════════════════════════════════════════════════════════════════════════════

  SITUATION D'URGENCE:
  ─────────────────────
  16h30: Bob signale sur Slack: "Le staging est mort. La base de données PostgreSQL
  ne redémarre plus. J'ai essayé: le disque de données est corrompu."

  SANS TERRAFORM + ANSIBLE (avant):
  ─────────────────────────────────
  -> Alice passe 3-4 heures à reconfigurer à la main
  -> Elle risque d'oublier des configurations
  -> Bob et Claire ne peuvent pas travailler de la soirée

  AVEC TERRAFORM + ANSIBLE (maintenant):
  ──────────────────────────────────────
  -> Alice detruit et recrée en 15-20 minutes. Exactement identique.

  ALICE DANS SON TERMINAL:
  ─────────────────────────

  ── ÉTAPE A: Vérifier l'état actuel ─────────────────────────────────────────

  cd infrastructure/terraform/environments/staging/

  terraform show | grep status
  # -> status = "active"  <- Le serveur tourne, mais PostgreSQL est cassé

  # Essayer de voir le problème:
  ssh ubuntu@164.90.154.23 "sudo journalctl -u postgresql@15-main -n 50"
  # -> FATAL: could not write to file "/var/lib/postgresql/15/main/PG_VERSION"
  # Confirmation: le volume de données est corrompu.

  ── ÉTAPE B: Détruire le serveur problématique ───────────────────────────────

  terraform destroy -target=digitalocean_droplet.taskmanager_staging \
                   -target=digitalocean_volume_attachment.staging_data \
                   -target=digitalocean_volume.staging_data

  # -target = détruire SEULEMENT ces ressources (pas le VPC ni le firewall)
  # Le VPC et le firewall peuvent rester (ils ne sont pas cassés).

  CE QUE VOUS VOYEZ:
  ───────────────────
  Plan: 0 to add, 0 to change, 3 to destroy.

  Do you really want to destroy all resources?
  Enter a value: yes

  digitalocean_volume_attachment.staging_data: Destroying...
  digitalocean_volume_attachment.staging_data: Destruction complete after 5s
  digitalocean_droplet.taskmanager_staging: Destroying...
  digitalocean_droplet.taskmanager_staging: Still destroying... [30s elapsed]
  digitalocean_droplet.taskmanager_staging: Destruction complete after 52s
  digitalocean_volume.staging_data: Destroying...
  digitalocean_volume.staging_data: Destruction complete after 4s

  Destroy complete! Resources: 3 destroyed.

  ── ÉTAPE C: Recréer le serveur identique ───────────────────────────────────

  terraform apply \
    -target=digitalocean_droplet.taskmanager_staging \
    -target=digitalocean_volume.staging_data \
    -target=digitalocean_volume_attachment.staging_data

  CE QUE VOUS VOYEZ:
  ───────────────────
  Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

  Outputs:
  staging_ip = "164.90.154.88"   <- Nouvelle IP (l'ancienne est perdue)
  ssh_command = "ssh ubuntu@164.90.154.88"

  # [ATTENTION] L'IP A CHANGÉ. Alice doit mettre à jour l'inventaire Ansible:
  sed -i 's/164.90.154.23/164.90.154.88/' infrastructure/ansible/inventory/staging.ini

  ── ÉTAPE D: Reconfigurer le serveur depuis zéro ────────────────────────────

  cd infrastructure/ansible/

  ansible-playbook -i inventory/staging.ini site.yml \
    --vault-password-file ~/.vault_pass_staging

  # 15 minutes plus tard:
  # PLAY RECAP: ok=47   changed=31   unreachable=0    failed=0

  ── ÉTAPE E: Vérification ────────────────────────────────────────────────────

  curl http://164.90.154.88/health
  # -> {"status": "healthy", "database": "ok"}  [OK]

  # Alice envoie sur Slack à 17h05:
  # "[OK] Staging de retour! Nouvelle IP: 164.90.154.88. Durée panne: 35 minutes."

  # REMARQUE IMPORTANTE:
  # Les DONNÉES PostgreSQL sont perdues (la base corrompue était le problème).
  # En staging, ce n'est pas grave (données de test).
  # En PRODUCTION, c'est pourquoi:
  # -> enable_backups = true (sauvegardes DigitalOcean automatiques)
  # -> maintenance.yml fait des backups pg_dump quotidiens
  # -> Le volume PostgreSQL est SÉPARÉ du serveur (survit à la destruction du serveur)

════════════════════════════════════════════════════════════════════════════════
SCÉNARIO 6 — ALICE AUGMENTE LA CAPACITÉ EN PRODUCTION (SCALING)
Contexte: L'API reçoit beaucoup plus de trafic. Il faut un serveur plus puissant.
════════════════════════════════════════════════════════════════════════════════

  SITUATION:
  ───────────
  Les alertes Prometheus montrent: CPU > 85% en permanence sur le serveur prod.
  Gunicorn est saturé. Alice doit passer à un serveur plus puissant.

  AVEC TERRAFORM: changer 1 variable suffit.

  ALICE DANS SON TERMINAL:
  ─────────────────────────

  cd infrastructure/terraform/environments/production/

  # Modifier la taille du serveur:
  nano terraform.tfvars
  # Changer: droplet_size = "s-4vcpu-8gb"  ->  droplet_size = "s-8vcpu-16gb"

  # Voir l'impact:
  terraform plan

  CE QUE VOUS VOYEZ (IMPORTANT):
  ─────────────────────────────────
  # digitalocean_droplet.taskmanager_production must be replaced
  -/+ resource "digitalocean_droplet" "taskmanager_production" {
        ~ id   = "123456" -> (known after apply)
        ~ size = "s-4vcpu-8gb" -> "s-8vcpu-16gb"   # forcenew = true!
      }

  Plan: 1 to add, 0 to change, 1 to destroy.

  # ATTENTION! DigitalOcean DOIT recréer le serveur pour changer sa taille.
  # Cela signifie: ~2 minutes de downtime pour l'API!
  # Alice prévient l'équipe et les utilisateurs avant d'appliquer.

  # Alice planifie la maintenance à 2h du matin (trafic minimal):
  terraform apply   # À 2h du matin

  # Après recreation du serveur, RECONFIGURER avec Ansible:
  # (Le nouveau serveur est vide, il faut tout réinstaller)
  ansible-playbook -i inventory/production.ini site.yml \
    --vault-password-file ~/.vault_pass_prod

  # Déployer la dernière version:
  ansible-playbook -i inventory/production.ini deploy.yml \
    --vault-password-file ~/.vault_pass_prod \
    -e "app_version=v1.0.0"

  # [OK] Le serveur est maintenant 2x plus puissant!

  # Sur Grafana (monitoring):
  # CPU: 85% -> 32% après le scaling
  # Temps de réponse: 450ms -> 95ms

================================================================================
PARTIE 8 — TERRAFORM AVANCÉ: TOUTES LES FONCTIONNALITÉS
================================================================================

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 8.1 — TERRAFORM WORKSPACES: PLUSIEURS ENVIRONNEMENTS AVEC UN SEUL CODE
────────────────────────────────────────────────────────────────────────────────

  POURQUOI LES WORKSPACES?
  ─────────────────────────
  Jusqu'ici: staging et production = dossiers séparés avec leurs propres .tf.
  Les workspaces offrent une ALTERNATIVE: un seul dossier .tf avec des states séparés.

  Chaque workspace = son propre state Terraform isolé.
  Même code .tf -> comportement différent selon le workspace actif.

  QUAND UTILISER LES WORKSPACES?
  ────────────────────────────────
  [OK] Utile pour: des environnements TRÈS similaires (staging ≈ prod, juste tailles différentes)
  [X] Moins adapté pour: des environnements très différents (architectures séparées)

  COMMANDES:
  ───────────
  cd infrastructure/terraform/environments/staging/

  # Voir le workspace actuel:
  terraform workspace show
  # -> default  (workspace par défaut)

  # Créer un workspace "staging":
  terraform workspace new staging

  CE QUE VOUS VOYEZ:
  ───────────────────
  Created and switched to workspace "staging"!
  You're now on a new, empty workspace. Workspaces isolate their state,
  so if you run "terraform plan" Terraform will not see any existing state
  for this configuration.

  # Créer un workspace "production":
  terraform workspace new production

  # Lister les workspaces:
  terraform workspace list

  CE QUE VOUS VOYEZ:
  ───────────────────
    default
    staging
  * production   <- Le * indique le workspace actif

  # Changer de workspace:
  terraform workspace select staging

  # Supprimer un workspace (il doit être vide):
  terraform workspace delete my-test-workspace

  UTILISER LES WORKSPACES DANS LE CODE HCL:
  ───────────────────────────────────────────
  # La variable terraform.workspace contient le nom du workspace actif.
  # On peut l'utiliser pour adapter le comportement selon l'environnement:

  # Dans main.tf:
  locals {
    # Configuration selon le workspace (environnement)
    config = {
      staging = {
        droplet_size        = "s-2vcpu-4gb"
        enable_backups      = false
        gunicorn_workers    = 2
        db_volume_size      = 20
        monitoring_enabled  = false
      }
      production = {
        droplet_size        = "s-4vcpu-8gb"
        enable_backups      = true
        gunicorn_workers    = 4
        db_volume_size      = 50
        monitoring_enabled  = true
      }
    }
    # Utiliser la config du workspace actif (ou "staging" si workspace inconnu):
    env = lookup(local.config, terraform.workspace, local.config["staging"])
  }

  # Utilisation:
  resource "digitalocean_droplet" "taskmanager" {
    name    = "taskmanager-${terraform.workspace}"
    size    = local.env.droplet_size
    backups = local.env.enable_backups
  }

  # Pour déployer en staging:
  terraform workspace select staging
  terraform apply   # -> Crée taskmanager-staging avec s-2vcpu-4gb

  # Pour déployer en production:
  terraform workspace select production
  terraform apply   # -> Crée taskmanager-production avec s-4vcpu-8gb

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 8.2 — TERRAFORM DATA SOURCES: LIRE DES RESSOURCES EXISTANTES
────────────────────────────────────────────────────────────────────────────────

  POURQUOI LES DATA SOURCES?
  ───────────────────────────
  Terraform distingue deux types d'accès aux ressources:
  - resource = Terraform GÈRE (crée/modifie/détruit) cette ressource
  - data     = Terraform LIT seulement cette ressource (elle existe déjà)

  EXEMPLES DE DATA SOURCES UTILES:
  ──────────────────────────────────

  # Récupérer l'ID d'une clé SSH existante sur DigitalOcean:
  data "digitalocean_ssh_key" "alice" {
    name = "alice-macbook-2024"   # Nom de la clé sur DigitalOcean
  }
  # Utilisation: ssh_keys = [data.digitalocean_ssh_key.alice.id]

  # Récupérer un domaine existant sur DigitalOcean:
  data "digitalocean_domain" "equipe" {
    name = "equipe.com"
  }
  # Utilisation: domain = data.digitalocean_domain.equipe.name

  # Récupérer l'image Ubuntu la plus récente:
  data "digitalocean_images" "ubuntu_22" {
    filter {
      key    = "distribution"
      values = ["Ubuntu"]
    }
    filter {
      key    = "name"
      values = ["22.04 (LTS)"]
    }
    sort {
      key       = "created"
      direction = "desc"
    }
  }
  # Utilisation: image = data.digitalocean_images.ubuntu_22.images[0].slug

  # Récupérer les IPs autorisées depuis un autre module Terraform:
  data "terraform_remote_state" "network" {
    backend = "s3"
    config = {
      bucket = "taskmanager-terraform-state"
      key    = "network/terraform.tfstate"
      region = "eu-west-3"
    }
  }
  # Utilisation: vpc_id = data.terraform_remote_state.network.outputs.vpc_id

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 8.3 — TERRAFORM LOCALS: CALCULS ET CONSTANTES
────────────────────────────────────────────────────────────────────────────────

  POURQUOI LES LOCALS?
  ─────────────────────
  Les locals permettent de définir des expressions réutilisables dans le code HCL.
  Évite la répétition. Centralise les calculs complexes.

  EXEMPLES PRATIQUES POUR NOTRE PROJET:
  ──────────────────────────────────────

  # Dans main.tf:
  locals {
    # Nom de l'environnement normalisé:
    env_name = lower(var.environment)

    # Préfixe pour nommer toutes les ressources:
    name_prefix = "${var.project_name}-${local.env_name}"

    # Tags communs appliqués à toutes les ressources:
    common_tags = concat(var.common_tags, [local.env_name, "terraform-managed"])

    # Calculer la taille des buffers PostgreSQL (25% de la RAM):
    # (Terraform peut appeler l'API pour connaître la RAM du Droplet choisi)
    # Ici on utilise une valeur approximative selon la taille:
    postgres_shared_buffers = {
      "s-1vcpu-2gb"  = "512MB"
      "s-2vcpu-4gb"  = "1GB"
      "s-4vcpu-8gb"  = "2GB"
      "s-8vcpu-16gb" = "4GB"
    }

    # Nom DNS selon l'environnement:
    api_subdomain = local.env_name == "production" ? "api" : "api-${local.env_name}"
    # "api" pour prod, "api-staging" pour staging, "api-test" pour test
  }

  # Utilisation:
  resource "digitalocean_droplet" "taskmanager" {
    name = "${local.name_prefix}-server"
    tags = local.common_tags
  }

  resource "digitalocean_record" "api" {
    name = local.api_subdomain
  }

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 8.4 — TERRAFORM COUNT ET FOR_EACH: CRÉER PLUSIEURS RESSOURCES
────────────────────────────────────────────────────────────────────────────────

  POURQUOI COUNT ET FOR_EACH?
  ────────────────────────────
  Créer 3 serveurs identiques avec 3 blocs resource = code répété et non maintenable.
  count et for_each permettent de créer N ressources avec une seule déclaration.

  ── AVEC COUNT: créer N ressources identiques ───────────────────────────────

  # Créer 3 serveurs Flask (pour un cluster):
  resource "digitalocean_droplet" "flask_servers" {
    count = var.flask_server_count   # Variable: 1 en staging, 3 en prod

    name   = "${var.project_name}-web-${count.index + 1}"
    # count.index = 0, 1, 2
    # -> taskmanager-web-1, taskmanager-web-2, taskmanager-web-3
    region = var.region
    size   = var.droplet_size
    image  = "ubuntu-22-04-x64"
  }

  # Accéder à toutes les IPs dans les outputs:
  output "flask_server_ips" {
    value = digitalocean_droplet.flask_servers[*].ipv4_address
    # -> ["164.90.1.1", "164.90.1.2", "164.90.1.3"]
  }

  # Accéder à un serveur spécifique:
  output "first_server_ip" {
    value = digitalocean_droplet.flask_servers[0].ipv4_address
  }

  ── AVEC FOR_EACH: créer des ressources différentes depuis un map ────────────

  # Créer des enregistrements DNS pour plusieurs sous-domaines:
  variable "dns_records" {
    default = {
      api     = { name = "api",     type = "A" }
      staging = { name = "api-staging", type = "A" }
      www     = { name = "www",     type = "CNAME" }
    }
  }

  resource "digitalocean_record" "records" {
    for_each = var.dns_records

    domain = "equipe.com"
    type   = each.value.type
    name   = each.value.name
    value  = digitalocean_droplet.taskmanager.ipv4_address
    # each.key   = "api", "staging", "www"
    # each.value = l'objet correspondant
  }

  # FOR_EACH vs COUNT:
  # count = ressources identiques numérotées (0, 1, 2...)
  # for_each = ressources différentes avec des clés explicites (api, staging, www)
  # Si vous supprimez une ressource count[1]: Terraform renumérote -> recréation!
  # Si vous supprimez une ressource for_each["staging"]: seulement celle-là est supprimée

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 8.5 — TERRAFORM EXPRESSIONS ET FONCTIONS
────────────────────────────────────────────────────────────────────────────────

  TERRAFORM A DES FONCTIONS INTÉGRÉES TRÈS UTILES:
  ──────────────────────────────────────────────────

  # Dans outputs.tf ou main.tf, dans un bloc locals:
  locals {
    # Fonctions de chaînes:
    upper_env  = upper(var.environment)    # "STAGING"
    lower_env  = lower(var.environment)    # "staging"
    env_slug   = replace(var.environment, "/[^a-z]/", "-")  # Remplacer non-alphanum
    env_length = length(var.environment)   # Nombre de caractères

    # Fonctions de listes:
    all_ips    = concat(["10.0.0.1"], ["10.0.0.2"])   # Fusionner listes
    unique_ips = distinct(["10.0.0.1", "10.0.0.1"])   # Supprimer doublons
    sorted_ips = sort(["10.0.0.3", "10.0.0.1"])        # Trier

    # Fonctions de maps:
    lookup_size = lookup({"staging" = "small", "prod" = "large"}, var.env, "small")
    merged_tags = merge(var.common_tags, {"env" = var.environment})

    # Fonctions conditionnelles:
    db_backup = var.environment == "production" ? true : false
    workers   = var.environment == "production" ? 4 : 2

    # Fonctions de fichiers:
    user_data = file("scripts/init.sh")   # Lire le contenu d'un fichier
    user_data_b64 = base64encode(file("scripts/init.sh"))  # En base64

    # Fonctions d'encodage:
    json_config = jsonencode({"key" = "value"})   # Encoder en JSON
    yaml_output = yamlencode({"key" = "value"})   # Encoder en YAML

    # Fonctions réseau:
    cidr_range = cidrsubnet("10.0.0.0/16", 8, 1)  # Sous-réseau: 10.0.1.0/24
    # cidrsubnet(prefix, newbits, netnum):
    # "10.0.0.0/16" avec 8 nouveaux bits, sous-réseau n°1 -> "10.0.1.0/24"
  }

  EXEMPLES CONCRETS DANS NOTRE PROJET:
  ──────────────────────────────────────

  # Créer un VPC avec un sous-réseau calculé automatiquement:
  resource "digitalocean_vpc" "taskmanager" {
    name     = "taskmanager-${var.environment}-vpc"
    region   = var.region
    ip_range = cidrsubnet("10.0.0.0/8", 16, var.environment == "production" ? 1 : 2)
    # production -> 10.0.1.0/24
    # staging    -> 10.0.2.0/24
  }

  # User data dynamique avec templatefile():
  resource "digitalocean_droplet" "taskmanager" {
    user_data = templatefile("${path.module}/scripts/init.sh.tpl", {
      app_user     = var.app_user
      app_version  = var.app_version
      environment  = var.environment
    })
    # templatefile lit un fichier template et remplace les variables ${var}
  }

  # Fichier scripts/init.sh.tpl:
  # #!/bin/bash
  # echo "Initializing ${environment} server"
  # useradd -m ${app_user}
  # echo "APP_VERSION=${app_version}" >> /etc/environment

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 8.6 — TERRAFORM PROVISIONERS: ACTIONS POST-CRÉATION (À ÉVITER)
────────────────────────────────────────────────────────────────────────────────

  POURQUOI LES PROVISIONERS (ET POURQUOI LES ÉVITER)?
  ────────────────────────────────────────────────────
  Les provisioners permettent d'exécuter des scripts SUR le serveur après
  sa création par Terraform. On aurait pu les utiliser à la place d'Ansible.

  [ATTENTION] MAIS Terraform lui-même dit: "Provisioners are a last resort."
  Problèmes des provisioners:
  - Pas idempotents (ne vérifient pas si déjà fait)
  - Erreurs difficiles à diagnostiquer
  - Ils cassent la philosophie déclarative de Terraform
  - Pas de dry-run possible (--check)

  -> Utiliser Ansible pour la configuration. Terraform pour l'infrastructure.

  NÉANMOINS, VOICI COMMENT ÇA MARCHE:
  ──────────────────────────────────────

  resource "digitalocean_droplet" "taskmanager" {
    # ...

    # Connection SSH pour les provisioners:
    connection {
      type        = "ssh"
      host        = self.ipv4_address
      user        = "root"
      private_key = file("~/.ssh/id_ed25519_github")
      timeout     = "2m"
    }

    # Provisioner file: copier un fichier vers le serveur
    provisioner "file" {
      source      = "scripts/post_create.sh"
      destination = "/tmp/post_create.sh"
    }

    # Provisioner remote-exec: exécuter des commandes sur le serveur
    provisioner "remote-exec" {
      inline = [
        "chmod +x /tmp/post_create.sh",
        "/tmp/post_create.sh",
      ]
    }

    # Provisioner local-exec: exécuter des commandes sur votre machine locale
    provisioner "local-exec" {
      command = "ansible-playbook -i '${self.ipv4_address},' site.yml"
      # <- C'est AINSI qu'on peut lancer Ansible depuis Terraform automatiquement!
      # Après terraform apply, Ansible est lancé automatiquement.
    }

    # on_failure = que faire si le provisioner échoue?
    # "continue" = ignorer l'erreur, continuer
    # "fail"     = arrêter Terraform avec une erreur (défaut)
    provisioner "remote-exec" {
      on_failure = continue
      inline = ["echo 'test'"]
    }
  }

  # Provisioner null_resource: exécuter des actions sans créer de ressource:
  resource "null_resource" "ansible_provisioner" {
    # triggers = recréer ce null_resource si ces valeurs changent
    triggers = {
      server_id = digitalocean_droplet.taskmanager.id
      timestamp = timestamp()   # Toujours recréer (utile pour forcer un re-run)
    }

    connection {
      type        = "ssh"
      host        = digitalocean_droplet.taskmanager.ipv4_address
      user        = "root"
      private_key = file("~/.ssh/id_ed25519_github")
    }

    provisioner "local-exec" {
      command = <<-EOT
        sleep 30  # Attendre que le serveur démarre complètement
        ansible-playbook \
          -i '${digitalocean_droplet.taskmanager.ipv4_address},' \
          -u ubuntu \
          --private-key ~/.ssh/id_ed25519_github \
          infrastructure/ansible/site.yml \
          --vault-password-file ~/.vault_pass_staging
      EOT
    }
  }

  # WORKFLOW AUTOMATIQUE (terraform apply -> Ansible automatiquement):
  # 1. terraform apply crée le serveur
  # 2. Le provisioner local-exec est déclenché
  # 3. Ansible site.yml est lancé sur le nouveau serveur
  # 4. 20 minutes plus tard: tout est configuré et déployé!

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 8.7 — TERRAFORM IMPORT: GÉRER DES RESSOURCES CRÉÉES MANUELLEMENT
────────────────────────────────────────────────────────────────────────────────

  SITUATION RÉELLE:
  ──────────────────
  Avant Terraform, Bob avait créé un serveur manuellement sur DigitalOcean.
  Alice veut maintenant le gérer avec Terraform sans le recréer.

  POURQUOI terraform import?
  ───────────────────────────
  terraform import ajoute une ressource existante au state Terraform.
  Après l'import, Terraform la gère comme si elle avait été créée par Terraform.

  ÉTAPES:
  ──────────────────────────────────────────────────────────────────────────────

  # ÉTAPE 1: Trouver l'ID de la ressource à importer.
  # Sur DigitalOcean: dashboard -> Droplets -> cliquer sur le serveur
  # L'URL montre l'ID: https://cloud.digitalocean.com/droplets/123456789
  # ID = 123456789

  # ÉTAPE 2: Écrire le bloc resource dans main.tf
  # (Terraform a besoin que le bloc existe AVANT l'import)
  resource "digitalocean_droplet" "bob_legacy_server" {
    name   = "bob-test-server"
    region = "fra1"
    size   = "s-1vcpu-2gb"
    image  = "ubuntu-22-04-x64"
    # Les valeurs seront mises à jour depuis la réalité après l'import
  }

  # ÉTAPE 3: Importer
  terraform import digitalocean_droplet.bob_legacy_server 123456789

  CE QUE VOUS VOYEZ:
  ───────────────────
  digitalocean_droplet.bob_legacy_server: Importing from ID "123456789"...
  digitalocean_droplet.bob_legacy_server: Import prepared!
    Prepared digitalocean_droplet for import
  digitalocean_droplet.bob_legacy_server: Refreshing state... [id=123456789]

  Import successful!

  The resources that were imported are shown above. These resources are now in
  your Terraform state and will henceforth be managed by Terraform.

  # ÉTAPE 4: Synchroniser le code avec la réalité
  terraform plan
  # Terraform montre les différences entre votre bloc resource et la réalité.
  # Mettre à jour main.tf pour correspondre à la réalité (éviter des modifications).

  # ÉTAPE 5: Vérifier que plan = "No changes"
  terraform plan
  # -> No changes. Your infrastructure matches the configuration.
  # [OK] La ressource est maintenant gérée par Terraform!

  # AUTRES TYPES DE RESSOURCES QU'ON PEUT IMPORTER:
  terraform import digitalocean_firewall.my_firewall FIREWALL_ID
  terraform import digitalocean_vpc.my_vpc VPC_ID
  terraform import digitalocean_domain.my_domain equipe.com
  terraform import digitalocean_record.my_record DOMAIN_ID,RECORD_ID

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 8.8 — TERRAFORM MOVED: REFACTORISER SANS RECRÉER
────────────────────────────────────────────────────────────────────────────────

  SITUATION RÉELLE:
  ──────────────────
  Alice refactorise main.tf pour utiliser des modules.
  Elle renomme "digitalocean_droplet.taskmanager_staging" en
  "module.staging_server.digitalocean_droplet.this"

  Sans le bloc "moved": Terraform détruit l'ancien + recrée le nouveau.
  Avec le bloc "moved": Terraform fait juste un renommage dans le state. 0 downtime!

  DANS main.tf ou dans un fichier moved.tf:
  ──────────────────────────────────────────

  moved {
    from = digitalocean_droplet.taskmanager_staging
    to   = module.staging_server.digitalocean_droplet.this
  }

  # Terraform apply avec ce bloc:
  # -> "Note: Objects have moved" (pas de destroy/create)
  # -> Renomme simplement dans le state

  # Après avoir appliqué et confirmé que tout fonctionne:
  # -> Supprimer le bloc moved (il n'est plus nécessaire)
  # -> Le state contient maintenant le nouveau nom

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 8.9 — TERRAFORM STATE: OPÉRATIONS AVANCÉES
────────────────────────────────────────────────────────────────────────────────

  TOUTES LES COMMANDES TERRAFORM STATE:
  ──────────────────────────────────────

  # Lister toutes les ressources dans le state:
  terraform state list

  # Voir les détails d'une ressource:
  terraform state show digitalocean_droplet.taskmanager_staging

  # Supprimer une ressource du state SANS la détruire:
  # Cas: on veut "oublier" une ressource (elle continuera d'exister)
  terraform state rm digitalocean_droplet.taskmanager_staging
  # [ATTENTION] Danger: la ressource n'est plus gérée par Terraform mais elle existe encore.

  # Déplacer une ressource dans le state (renommage):
  terraform state mv \
    digitalocean_droplet.taskmanager_staging \
    module.staging_server.digitalocean_droplet.this

  # Remplacer le state par une version précédente (urgence):
  # 1. Télécharger le state depuis S3/Terraform Cloud
  # 2. Modifier si nécessaire
  # 3. Remettre:
  terraform state push terraform-backup.tfstate
  # [ATTENTION] TRÈS DANGEREUX: ne faire qu'en urgence absolue

  # Forcer la mise à jour du state depuis la réalité:
  terraform refresh
  # Note: Deprecated en faveur de terraform apply -refresh-only

  # Refresh uniquement (voir les différences sans modifier):
  terraform apply -refresh-only
  # Montre ce qui a changé OUTSIDE de Terraform (modifications manuelles)

  # Suppression ciblée d'une ressource (sans toucher aux autres):
  terraform destroy -target=digitalocean_droplet.taskmanager_staging
  # Supprime seulement ce serveur, garde le VPC, le firewall, etc.

  GESTION DU LOCK TERRAFORM:
  ───────────────────────────
  # Si Terraform plante pendant un apply, il peut laisser un lock actif.
  # Bob essaie de faire terraform apply:
  # -> "Error: Error acquiring the state lock"
  # -> "Lock Info: ID: 12345678, Path: terraform.state, Operation: apply"

  # Forcer la libération du lock (SEULEMENT si vous êtes SÛRS que personne ne tourne):
  terraform force-unlock 12345678
  # DANGER: ne jamais faire si un autre Terraform est vraiment en cours!

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 8.10 — TERRAFORM CLOUD: BACKEND PARTAGÉ EN ÉQUIPE
────────────────────────────────────────────────────────────────────────────────

  POURQUOI TERRAFORM CLOUD?
  ──────────────────────────
  En équipe, tout le monde doit partager le même state.
  Terraform Cloud = state stocké en ligne, accessible par l'équipe.
  Gratuit jusqu'à 5 utilisateurs (suffisant pour Alice, Bob, Claire).

  FONCTIONNALITÉS TERRAFORM CLOUD:
  ──────────────────────────────────
  -> State partagé et versionné (historique des states)
  -> Verrou automatique (2 personnes ne peuvent pas apply simultanément)
  -> Interface web pour voir l'état de l'infrastructure
  -> Plans visibles dans l'UI avant d'appliquer
  -> Notifications Slack/email lors des applies
  -> Variables chiffrées stockées dans le cloud (plus de terraform.tfvars)
  -> Intégration GitHub: plan automatique sur chaque PR

  CONFIGURATION TERRAFORM CLOUD:
  ────────────────────────────────

  # ÉTAPE 1: Créer un compte sur app.terraform.io
  # ÉTAPE 2: Créer une organisation: "equipe-taskmanager"
  # ÉTAPE 3: Créer les workspaces: "taskmanager-staging" et "taskmanager-production"

  # ÉTAPE 4: Ajouter le backend dans main.tf:
  terraform {
    cloud {
      organization = "equipe-taskmanager"
      workspaces {
        name = "taskmanager-staging"
      }
    }
  }

  # ÉTAPE 5: S'authentifier:
  terraform login

  CE QUE VOUS VOYEZ:
  ───────────────────
  Terraform will request an API token for app.terraform.io using your browser.

  If login is successful, Terraform will store the token in plain text in
  the following file for use by subsequent commands:
      /home/alice/.terraform.d/credentials.tfrc.json

  Do you want to proceed? (yes/no) yes
  # -> Ouvre le navigateur -> Generate token -> Coller le token dans le terminal

  Token saved! Terraform is now authenticated.

  # ÉTAPE 6: Réinitialiser (migre le state local vers le cloud):
  terraform init

  CE QUE VOUS VOYEZ:
  ───────────────────
  Initializing Terraform Cloud...
  Do you wish to proceed? (yes/no) yes

  Migrating existing state to Terraform Cloud...
  Successfully migrated Terraform state to Terraform Cloud!

  # [OK] Le state est maintenant dans Terraform Cloud. Accessible par toute l'équipe.

  # ÉTAPE 7: Stocker les variables dans Terraform Cloud (plus de terraform.tfvars):
  # Sur app.terraform.io -> Workspace -> Variables -> Add variable
  # Ajouter: do_token = dop_v1_... (cocher "Sensitive" pour cacher la valeur)
  # Ajouter: region = fra1
  # Ajouter: droplet_size = s-2vcpu-4gb

  # Maintenant Alice peut supprimer terraform.tfvars (les variables sont dans le cloud).
  # Terraform apply sans aucun argument: tout est configuré dans Terraform Cloud.

  # INTÉGRATION GITHUB ACTIONS AVEC TERRAFORM CLOUD:
  # Créer un token API Terraform Cloud:
  # app.terraform.io -> User Settings -> Tokens -> Create API token
  # Ajouter dans GitHub Secrets: TF_API_TOKEN = le token

  # Dans .github/workflows/terraform.yml:
  - name: Terraform apply
    env:
      TF_TOKEN_app_terraform_io: ${{ secrets.TF_API_TOKEN }}
    run: terraform apply -auto-approve

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 8.11 — TERRAFORM GRAPH: VISUALISER LES DÉPENDANCES
────────────────────────────────────────────────────────────────────────────────

  POURQUOI TERRAFORM GRAPH?
  ──────────────────────────
  Terraform crée les ressources dans le BON ORDRE automatiquement.
  Il analyse les dépendances entre ressources.
  terraform graph génère un graphique de ces dépendances.

  COMMANDES:
  ───────────
  # Générer le graphique en format DOT (Graphviz):
  terraform graph

  # Visualiser avec Graphviz (si installé):
  terraform graph | dot -Tpng > infrastructure_graph.png
  # -> Ouvre infrastructure_graph.png pour voir les dépendances visuellement

  # Ou en ligne: copier le output DOT sur https://dreampuf.github.io/GraphvizOnline

  # Graphique du plan seulement (pas tout le state):
  terraform graph -type=plan

  CE QUE VOUS VOYEZ (format DOT):
  ────────────────────────────────
  digraph {
    compound = "true"
    ...
    "[root] digitalocean_droplet.taskmanager (expand)" ->
      "[root] digitalocean_vpc.taskmanager (expand)"
    # ^ Le Droplet dépend du VPC -> le VPC sera créé EN PREMIER

    "[root] digitalocean_volume_attachment.data (expand)" ->
      "[root] digitalocean_droplet.taskmanager (expand)"
    # ^ L'attachement de volume dépend du Droplet

    "[root] digitalocean_record.api (expand)" ->
      "[root] digitalocean_droplet.taskmanager (expand)"
    # ^ L'enregistrement DNS dépend du Droplet (pour connaître son IP)
  }

  # L'ORDRE DE CRÉATION QUE TERRAFORM DÉDUIRA:
  # 1. VPC (pas de dépendances)
  # 2. Firewall (dépend des tags, pas du VPC)
  # 3. Droplet (dépend du VPC)
  # 4. Volume (peut être créé en parallèle avec le Droplet)
  # 5. Volume attachment (dépend du Droplet ET du Volume)
  # 6. DNS record (dépend du Droplet pour connaître son IP)


================================================================================
PARTIE 9 — ANSIBLE AVANCÉ: TOUTES LES FONCTIONNALITÉS
================================================================================

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 9.1 — ANSIBLE FACTS PERSONNALISÉS
────────────────────────────────────────────────────────────────────────────────

  POURQUOI LES FACTS PERSONNALISÉS?
  ───────────────────────────────────
  En plus des facts automatiques (RAM, OS, IP...), vous pouvez créer vos propres
  facts. Exemple: stocker sur le serveur la version déployée, la date du dernier
  déploiement, etc. Ces facts sont disponibles dans tous les playbooks suivants.

  CRÉER DES FACTS PERSONNALISÉS:
  ────────────────────────────────

  # Dans un role ou task:
  - name: Créer le fichier de facts personnalisés
    copy:
      content: |
        {
          "taskmanager": {
            "version": "{{ app_version }}",
            "deployed_at": "{{ ansible_date_time.iso8601 }}",
            "deployed_by": "{{ ansible_user }}"
          }
        }
      dest: /etc/ansible/facts.d/taskmanager.fact
      mode: '0644'
    # Ansible charge automatiquement les fichiers *.fact dans /etc/ansible/facts.d/

  # Les facts sont disponibles sous:
  # ansible_local.taskmanager.version
  # ansible_local.taskmanager.deployed_at

  # Dans un playbook suivant:
  - name: Afficher la version actuellement déployée
    debug:
      msg: "Version sur {{ inventory_hostname }}: {{ ansible_local.taskmanager.version }}"

  # Forcer le rechargement des facts (si vous venez de les créer):
  - name: Recharger les facts
    setup:
      filter: ansible_local

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 9.2 — ANSIBLE VARIABLES: PRIORITÉS ET SURCHARGES
────────────────────────────────────────────────────────────────────────────────

  ANSIBLE A 22 NIVEAUX DE PRIORITÉ DE VARIABLES (du plus faible au plus fort):
  ────────────────────────────────────────────────────────────────────────────────

  1.  command line values (e.g., -u, -k)
  2.  role defaults          -> roles/nginx/defaults/main.yml
  3.  inventory file/script group vars -> inventory/staging.ini [web:vars]
  4.  inventory group_vars/all    -> group_vars/all.yml
  5.  playbook group_vars/all
  6.  inventory group_vars/*      -> group_vars/staging.yml
  7.  playbook group_vars/*
  8.  inventory host_vars/*       -> host_vars/taskmanager-staging.yml
  9.  playbook host_vars/*
  10. host facts / cached set_facts
  11. play vars
  12. play vars_prompt
  13. play vars_files
  14. role vars                   -> roles/nginx/vars/main.yml
  15. block vars
  16. task vars (only for the task)
  17. include_vars
  18. set_facts / registered vars
  19. role (and include_role) params
  20. include params
  21. extra vars (-e)             -> ansible-playbook site.yml -e "key=value"
  22. task include params

  # RÈGLE D'OR: -e (extra vars) sur la ligne de commande = TOUJOURS prioritaire.
  # Utile pour surcharger temporairement une variable:
  ansible-playbook site.yml -e "flask_port=8080 gunicorn_workers=6"

  # EXEMPLES DE SURCHARGE:
  # Déployer une version spécifique:
  ansible-playbook deploy.yml -e "app_version=v1.1.0"

  # Activer le debug temporairement:
  ansible-playbook deploy.yml -e "flask_debug=1"

  # Déployer en mode maintenance (bloquer le trafic pendant le déploiement):
  ansible-playbook deploy.yml -e "enable_maintenance_page=true"

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 9.3 — ANSIBLE CONDITIONALS: WHEN, FAILED_WHEN, CHANGED_WHEN
────────────────────────────────────────────────────────────────────────────────

  ── WHEN: exécuter une tâche seulement si une condition est vraie ───────────

  # Seulement en production:
  - name: Activer SSL
    template:
      src: ssl.nginx.j2
      dest: /etc/nginx/sites-enabled/ssl
    when: flask_env == "production"

  # Seulement si le serveur a assez de RAM:
  - name: Activer les buffers PostgreSQL étendus
    lineinfile:
      path: /etc/postgresql/15/main/postgresql.conf
      line: "work_mem = 256MB"
    when: ansible_memtotal_mb >= 8192   # Seulement si ≥ 8GB RAM

  # Seulement si un fichier n'existe pas:
  - name: Initialiser la base de données (seulement si vide)
    command: flask db init
    args:
      creates: "{{ app_dir }}/migrations"   # Ne pas exécuter si ce dossier existe

  # Seulement si un paquet n'est pas installé:
  - name: Vérifier si Nginx est installé
    command: which nginx
    register: nginx_check
    failed_when: false   # Ne pas échouer si nginx n'est pas trouvé
    changed_when: false

  - name: Installer Nginx seulement si manquant
    apt:
      name: nginx
      state: present
    when: nginx_check.rc != 0

  # Conditions multiples (AND):
  - name: Configurer Certbot
    command: certbot --nginx -d {{ domain }}
    when:
      - flask_env == "production"
      - enable_ssl | bool
      - ansible_distribution == "Ubuntu"

  # Conditions multiples (OR):
  - name: Alerter si RAM faible
    debug:
      msg: "[ATTENTION] Attention RAM faible"
    when: ansible_memtotal_mb < 2048 or ansible_memfree_mb < 256

  # Basé sur l'OS:
  - name: Installer les paquets (Debian/Ubuntu)
    apt:
      name: nginx
    when: ansible_os_family == "Debian"

  - name: Installer les paquets (RedHat/CentOS)
    yum:
      name: nginx
    when: ansible_os_family == "RedHat"

  ── FAILED_WHEN: personnaliser les conditions d'échec ──────────────────────

  # Par défaut: une commande qui retourne rc != 0 = ÉCHEC Ansible
  # Parfois vous voulez personnaliser ce comportement:

  - name: Vérifier l'état de la DB
    command: psql -U {{ postgres_user }} -c "SELECT 1"
    register: db_check
    failed_when: db_check.rc != 0 and "Connection refused" not in db_check.stderr
    # Ne considérer comme échec QUE si: rc != 0 ET le message n'est pas "Connection refused"
    # Si PostgreSQL n'est pas encore démarré -> pas une erreur (juste patienter)

  - name: Tenter de supprimer un fichier (ne pas échouer s'il n'existe pas)
    command: rm /tmp/lockfile
    register: rm_result
    failed_when: rm_result.rc != 0 and "No such file" not in rm_result.stderr

  ── CHANGED_WHEN: personnaliser les conditions de "changement" ─────────────

  # Ansible considère les commandes shell/command comme "changed" par défaut.
  # Mais parfois ce n'est pas vrai (commande en lecture seule).

  # Vérification qui ne "change" jamais rien:
  - name: Vérifier la version Python
    command: python3 --version
    register: python_version
    changed_when: false    # Cette commande ne change jamais rien sur le serveur

  # Vérification qui "change" seulement si la sortie contient quelque chose:
  - name: Appliquer les migrations DB
    command: "{{ venv_dir }}/bin/flask db upgrade"
    register: migration_result
    changed_when: "'No migrations to apply' not in migration_result.stdout"
    # "changed" seulement si des migrations ont réellement été appliquées

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 9.4 — ANSIBLE LOOPS: BOUCLES AVANCÉES
────────────────────────────────────────────────────────────────────────────────

  POURQUOI LES BOUCLES?
  ──────────────────────
  Répéter la même tâche pour plusieurs items (paquets, utilisateurs, fichiers...).

  ── LOOP DE BASE (remplace with_items) ──────────────────────────────────────

  - name: Créer plusieurs dossiers
    file:
      path: "{{ item }}"
      state: directory
      mode: '0755'
    loop:
      - /var/log/taskmanager
      - /var/log/nginx/taskmanager
      - /opt/taskmanager/uploads
      - /opt/taskmanager/cache

  ── LOOP AVEC DICTIONNAIRES ──────────────────────────────────────────────────

  - name: Créer plusieurs utilisateurs système
    user:
      name: "{{ item.name }}"
      shell: "{{ item.shell }}"
      groups: "{{ item.groups }}"
      system: yes
    loop:
      - { name: deploy,       shell: /bin/bash,  groups: deploy      }
      - { name: monitoring,   shell: /bin/false, groups: monitoring   }
      - { name: backup,       shell: /bin/false, groups: backup       }

  ── LOOP AVEC INDEX ──────────────────────────────────────────────────────────

  - name: Créer des fichiers numérotés
    copy:
      content: "Worker {{ item.index + 1 }}"
      dest: "/tmp/worker{{ item.index + 1 }}.txt"
    loop: "{{ range(5) | list | map('extract', {'0': 0, '1': 1, '2': 2, '3': 3, '4': 4}) }}"
    # Ou plus simple:
    with_sequence: start=1 end=5

  ── LOOP AVEC VARIABLE D'INDEX (loop_control) ────────────────────────────────

  - name: Déployer les workers Gunicorn
    template:
      src: gunicorn_worker.service.j2
      dest: "/etc/systemd/system/gunicorn-worker@{{ item }}.service"
    loop: "{{ range(gunicorn_workers) | list }}"
    loop_control:
      loop_var: item          # Renommer item (utile pour les boucles imbriquées)
      label: "Worker {{ item }}"   # Message affiché pendant l'exécution
      index_var: worker_index  # Variable avec l'index 0, 1, 2...
      pause: 1                 # Pause 1 seconde entre chaque itération

  ── LOOP CONDITIONNEL (until) ────────────────────────────────────────────────

  # Réessayer jusqu'à succès (polling/retry):
  - name: Attendre que PostgreSQL accepte les connexions
    command: pg_isready -h localhost -U {{ postgres_user }}
    register: pg_ready
    until: pg_ready.rc == 0
    retries: 30
    delay: 5
    changed_when: false
    # Réessaie toutes les 5 secondes, jusqu'à 30 fois (150 secondes max)

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 9.5 — ANSIBLE BLOCKS: REGROUPER ET GÉRER LES ERREURS
────────────────────────────────────────────────────────────────────────────────

  POURQUOI LES BLOCKS?
  ─────────────────────
  Un "block" regroupe plusieurs tâches. Utile pour:
  - Appliquer le même when/become/tags à plusieurs tâches
  - Gérer les erreurs (try/rescue/always comme en Python)

  ── BLOCK AVEC CONDITION PARTAGÉE ───────────────────────────────────────────

  - name: Configuration SSL (production seulement)
    block:
      - name: Installer Certbot
        apt:
          name: certbot
          state: present

      - name: Obtenir le certificat SSL
        command: "certbot certonly --nginx -d {{ domain }}"

      - name: Configurer le renouvellement automatique
        cron:
          job: "certbot renew --quiet"
          minute: "0"
          hour: "3"

    when: enable_ssl | bool   # Tout le block seulement en production avec SSL
    become: yes               # Tout le block avec sudo

  ── BLOCK AVEC GESTION D'ERREURS (try/rescue/always) ─────────────────────────

  - name: Déploiement avec rollback automatique en cas d'erreur
    block:
      # Tenter le déploiement:
      - name: Mettre à jour le code
        git:
          repo: "{{ app_repo }}"
          dest: "{{ app_dir }}"
          version: "{{ app_version }}"
        become_user: "{{ app_user }}"

      - name: Mettre à jour les dépendances
        pip:
          requirements: "{{ app_dir }}/requirements.txt"
          virtualenv: "{{ venv_dir }}"
        become_user: "{{ app_user }}"

      - name: Redémarrer l'application
        service:
          name: taskmanager
          state: restarted

      - name: Vérifier le health check
        uri:
          url: "http://localhost:{{ flask_port }}/health"
          status_code: 200

    rescue:
      # Si UNE des tâches dans block échoue, rescue est exécuté:
      - name: [X] Déploiement échoué — démarrage du rollback automatique
        debug:
          msg: "Erreur lors du déploiement de {{ app_version }}. Rollback vers la version précédente..."

      - name: Rollback vers le commit précédent
        git:
          repo: "{{ app_repo }}"
          dest: "{{ app_dir }}"
          version: "HEAD@{1}"   # Commit précédent dans Git
          force: yes
        become_user: "{{ app_user }}"

      - name: Redémarrer après rollback
        service:
          name: taskmanager
          state: restarted

      - name: Notifier Slack du rollback
        uri:
          url: "{{ slack_webhook_url }}"
          method: POST
          body_format: json
          body:
            text: "[ALERTE] ROLLBACK automatique sur {{ inventory_hostname }}! Déploiement {{ app_version }} a échoué."
        when: slack_webhook_url is defined

      - name: Faire échouer le play après rollback
        fail:
          msg: "Déploiement échoué et rollback effectué. Vérifier les logs."

    always:
      # Toujours exécuté (que block réussisse ou non):
      - name: Enregistrer le résultat dans le log de déploiement
        lineinfile:
          path: /var/log/taskmanager/deployments.log
          line: "{{ ansible_date_time.epoch }} | version={{ app_version }} | status={{ 'FAILED' if ansible_failed_task is defined else 'SUCCESS' }}"
          create: yes

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 9.6 — ANSIBLE TEMPLATES JINJA2 AVANCÉS
────────────────────────────────────────────────────────────────────────────────

  POURQUOI LES TEMPLATES AVANCÉS?
  ─────────────────────────────────
  Les templates Jinja2 sont plus puissants que de simples substitutions de variables.
  Ils peuvent contenir des conditions, des boucles, des filtres de transformation.

  ── FICHIER: roles/flask_app/templates/.env.j2 (version avancée) ───────────

  # .env — Généré par Ansible pour {{ inventory_hostname }}
  # Environnement: {{ flask_env }}
  # Version: {{ app_version }}
  # Généré le: {{ ansible_date_time.iso8601 }}
  # [ATTENTION] Ce fichier est généré automatiquement. NE PAS modifier manuellement.

  # ── Application ─────────────────────────────────────────────────────────
  FLASK_ENV={{ flask_env }}
  FLASK_DEBUG={{ '1' if flask_env == 'development' else '0' }}
  # Filtre ternaire: si development -> 1, sinon -> 0

  FLASK_APP=run.py
  SECRET_KEY={{ flask_secret_key }}

  APP_VERSION={{ app_version }}
  APP_NAME={{ app_name | title }}
  # Filtre title: met en majuscule la première lettre de chaque mot
  # "taskmanager" -> "Taskmanager"

  PORT={{ flask_port }}

  # ── Base de données ──────────────────────────────────────────────────────
  DATABASE_URL=postgresql://{{ postgres_user }}:{{ postgres_password }}@{{ postgres_host | default('localhost') }}:{{ postgres_port }}/{{ postgres_db }}
  # default('localhost') = si postgres_host n'est pas défini, utiliser 'localhost'

  POSTGRES_DB={{ postgres_db }}
  POSTGRES_USER={{ postgres_user }}
  POSTGRES_PASSWORD={{ postgres_password }}
  POSTGRES_HOST={{ postgres_host | default('localhost') }}
  POSTGRES_PORT={{ postgres_port }}
  POSTGRES_MAX_CONNECTIONS={{ postgres_max_connections | default(100) }}

  # ── Redis ──────────────────────────────────────────────────────────────
  {% if redis_password is defined and redis_password != "" %}
  REDIS_URL=redis://:{{ redis_password }}@localhost:{{ redis_port }}/0
  # Avec authentification (production)
  {% else %}
  REDIS_URL=redis://localhost:{{ redis_port }}/0
  # Sans authentification (staging/dev)
  {% endif %}

  # ── Logging ────────────────────────────────────────────────────────────
  LOG_LEVEL={{ log_level | upper }}
  # Filtre upper: met tout en majuscules
  # "warning" -> "WARNING"

  LOG_FILE=/var/log/taskmanager/app.log

  # ── Features flags (selon l'environnement) ──────────────────────────────
  {% for feature, enabled in feature_flags.items() %}
  FEATURE_{{ feature | upper | replace('-', '_') }}={{ 'true' if enabled else 'false' }}
  {% endfor %}
  # Boucle sur un dictionnaire de feature flags
  # feature_flags = {"rate-limiting": true, "caching": false}
  # -> FEATURE_RATE_LIMITING=true
  # -> FEATURE_CACHING=false

  # ── Monitoring ──────────────────────────────────────────────────────────
  PROMETHEUS_ENABLED={{ monitoring_enabled | string | lower }}
  METRICS_PATH=/metrics

  # ── URLs et CORS ────────────────────────────────────────────────────────
  API_BASE_URL=https://{{ domain }}/api
  CORS_ORIGINS={{ cors_origins | default(['*']) | join(',') }}
  # Filtre join: convertit une liste en chaîne séparée par des virgules
  # ["https://app.equipe.com", "https://admin.equipe.com"] -> "https://app...,https://admin..."

  ── FICHIER: roles/nginx/templates/taskmanager.nginx.j2 (version avancée) ──

  # taskmanager.nginx — Généré par Ansible
  # Dernière mise à jour: {{ ansible_date_time.iso8601 }}

  {% if enable_ssl | bool %}
  # Redirection HTTP -> HTTPS
  server {
      listen 80;
      listen [::]:80;
      server_name {{ domain }} www.{{ domain }};
      return 301 https://$host$request_uri;
  }
  {% endif %}

  # Serveur principal
  upstream taskmanager_backend {
  {% if flask_servers is defined %}
  {% for server_ip in flask_servers %}
      server {{ server_ip }}:{{ flask_port }} weight=1 max_fails=3 fail_timeout=30s;
  {% endfor %}
  {% else %}
      server 127.0.0.1:{{ flask_port }};
  {% endif %}
      keepalive 32;
  }
  # Si flask_servers = ["10.0.0.1", "10.0.0.2", "10.0.0.3"]:
  # -> Génère un upstream avec 3 backends (load balancing)
  # Sinon: un seul backend local

  server {
  {% if enable_ssl | bool %}
      listen 443 ssl http2;
      listen [::]:443 ssl http2;
  {% else %}
      listen 80;
      listen [::]:80;
  {% endif %}
      server_name {{ domain }};

  {% if enable_ssl | bool %}
      # Certificats SSL
      ssl_certificate     /etc/letsencrypt/live/{{ domain }}/fullchain.pem;
      ssl_certificate_key /etc/letsencrypt/live/{{ domain }}/privkey.pem;
      ssl_protocols       TLSv1.2 TLSv1.3;
      ssl_ciphers         {{ nginx_ssl_ciphers | default('ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512') }};
      ssl_prefer_server_ciphers on;
      ssl_session_cache   shared:SSL:10m;
      ssl_session_timeout 10m;
  {% endif %}

      # Headers de sécurité
  {% set security_headers = {
    'X-Frame-Options': 'SAMEORIGIN',
    'X-Content-Type-Options': 'nosniff',
    'X-XSS-Protection': '1; mode=block',
    'Referrer-Policy': 'strict-origin-when-cross-origin'
  } %}
  {% for header, value in security_headers.items() %}
      add_header {{ header }} "{{ value }}" always;
  {% endfor %}
  # Boucle sur un dictionnaire inline de headers -> plus maintenable!

      # Locations personnalisées (liste de dictionnaires)
  {% for location in custom_locations | default([]) %}
      location {{ location.path }} {
          {{ location.config | indent(8) }}
      }
  {% endfor %}
  # custom_locations = [
  #   {path: "/api/v1/", config: "proxy_pass http://...; proxy_timeout 30s;"}
  # ]

      location / {
          proxy_pass         http://taskmanager_backend;
          proxy_http_version 1.1;
          proxy_set_header   Upgrade $http_upgrade;
          proxy_set_header   Connection 'upgrade';
          proxy_set_header   Host $host;
          proxy_set_header   X-Real-IP $remote_addr;
          proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
          proxy_set_header   X-Forwarded-Proto $scheme;
          proxy_cache_bypass $http_upgrade;
      }

      # Logs
      access_log /var/log/nginx/taskmanager/access.log combined buffer=16k flush=5s;
      error_log  /var/log/nginx/taskmanager/error.log warn;
  }

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 9.7 — ANSIBLE ROLES AVEC DÉPENDANCES (META)
────────────────────────────────────────────────────────────────────────────────

  POURQUOI LES DÉPENDANCES DE ROLES?
  ────────────────────────────────────
  Le role flask_app DÉPEND du role common (pour avoir l'utilisateur deploy).
  Il DÉPEND aussi du role python (pour avoir le venv).
  Plutôt que de s'en souvenir dans site.yml, on peut déclarer ces dépendances
  dans le fichier meta/main.yml du role.

  nano roles/flask_app/meta/main.yml

  Contenu:
  ---
  galaxy_info:
    author: Alice Martin
    description: Deploy TaskManager Flask application
    license: MIT
    min_ansible_version: "2.14"
    platforms:
      - name: Ubuntu
        versions:
          - "22.04"
    galaxy_tags:
      - flask
      - python
      - web
      - deployment

  # Dépendances: ces roles seront automatiquement exécutés AVANT flask_app
  dependencies:
    - role: common
      vars:
        app_user: "{{ flask_app_user | default('deploy') }}"
    # Si quelqu'un inclut flask_app dans un playbook, common est automatiquement
    # inclus et exécuté en premier.

  # NOTE: Les dépendances peuvent créer des cycles -> Ansible les détecte et refuse.
  # common -> flask_app: OK
  # flask_app -> common: OK
  # flask_app -> nginx -> flask_app: CYCLE -> ERREUR

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 9.8 — ANSIBLE GALAXY: TÉLÉCHARGER DES ROLES COMMUNAUTAIRES
────────────────────────────────────────────────────────────────────────────────

  POURQUOI ANSIBLE GALAXY?
  ─────────────────────────
  Des milliers de roles Ansible sont déjà écrits et maintenus par la communauté.
  Plutôt que d'écrire votre propre role Nginx, utilisez celui de geerlingguy.
  Ansible Galaxy = le PyPI d'Ansible.

  COMMANDES:
  ───────────
  # Chercher un role:
  ansible-galaxy search nginx
  # -> geerlingguy.nginx (52892 téléchargements)

  # Voir les détails d'un role:
  ansible-galaxy info geerlingguy.nginx

  # Installer un role:
  ansible-galaxy install geerlingguy.nginx
  # -> Installé dans ~/.ansible/roles/geerlingguy.nginx

  # Installer dans le dossier local du projet:
  ansible-galaxy install geerlingguy.nginx -p roles/
  # -> Installé dans infrastructure/ansible/roles/geerlingguy.nginx

  # Installer une version spécifique:
  ansible-galaxy install geerlingguy.nginx,3.2.0

  GÉRER LES DÉPENDANCES AVEC REQUIREMENTS.YML:
  ──────────────────────────────────────────────
  Comme requirements.txt pour Python, un fichier requirements.yml liste
  les roles externes du projet.

  nano infrastructure/ansible/requirements.yml

  Contenu:
  ---
  # Roles Ansible Galaxy à installer pour ce projet
  # Installation: ansible-galaxy install -r requirements.yml

  roles:
    # Nginx (alternative à notre role custom):
    - name: geerlingguy.nginx
      version: "3.2.0"       # Version fixée pour reproductibilité

    # Certbot/Let's Encrypt:
    - name: geerlingguy.certbot
      version: "6.1.0"

    # Firewall UFW:
    - name: weareinteractive.ufw
      version: "2.2.0"

    # PostgreSQL:
    - name: geerlingguy.postgresql
      version: "3.5.2"

  # Collections Ansible (comme des packages de modules):
  collections:
    - name: community.general
      version: "8.0.0"
      # Contient: community.general.timezone, docker_container, etc.

    - name: community.postgresql
      version: "3.2.0"
      # Contient: community.postgresql.postgresql_db, postgresql_user, etc.

    - name: community.docker
      version: "3.4.0"
      # Contient: community.docker.docker_container, docker_image, etc.

  # Installer tout:
  ansible-galaxy install -r requirements.yml
  ansible-galaxy collection install -r requirements.yml

  # Mettre à jour les roles (attention: peut casser des choses si versions non fixées):
  ansible-galaxy install -r requirements.yml --force

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 9.9 — ANSIBLE PLUGINS: CALLBACKS, LOOKUPS, FILTERS
────────────────────────────────────────────────────────────────────────────────

  ── CALLBACK PLUGINS: personnaliser le format d'affichage ───────────────────

  # Dans ansible.cfg:
  [defaults]
  stdout_callback = yaml    # Affichage YAML (plus lisible)
  # Autres options: json, minimal, dense, debug, unixy, community.general.yaml

  # Pour les tests CI/CD (JUnit XML pour GitHub Actions):
  [defaults]
  callbacks_enabled = ansible.posix.profile_tasks, community.general.junit
  # profile_tasks = affiche le temps de chaque tâche
  # junit = génère un rapport XML (lisible par GitHub Actions)

  ── LOOKUP PLUGINS: récupérer des données depuis l'extérieur ─────────────────

  # Lire un fichier local:
  - name: Déployer la clé SSH publique
    authorized_key:
      user: deploy
      key: "{{ lookup('file', '~/.ssh/id_ed25519_github.pub') }}"
      # Lit le contenu du fichier local et l'utilise comme valeur

  # Lire une variable d'environnement:
  - name: Utiliser une variable d'environnement
    debug:
      msg: "App version: {{ lookup('env', 'APP_VERSION') }}"

  # Récupérer un secret depuis HashiCorp Vault:
  - name: Récupérer le secret DB depuis Vault
    set_fact:
      db_password: "{{ lookup('hashi_vault', 'secret=secret/data/taskmanager/db:password') }}"

  # Récupérer un secret depuis AWS Secrets Manager:
  - name: Récupérer depuis AWS Secrets Manager
    set_fact:
      api_key: "{{ lookup('amazon.aws.aws_secret', 'taskmanager/api-key') }}"

  # Lire tous les fichiers dans un dossier:
  - name: Déployer les scripts SQL
    command: "psql -U {{ postgres_user }} -f {{ item }}"
    loop: "{{ lookup('fileglob', 'files/sql/*.sql', wantlist=True) }}"
    # fileglob retourne la liste de tous les fichiers .sql dans files/sql/

  ── FILTER PLUGINS: transformer des données ───────────────────────────────────

  # Filtres intégrés utiles:
  vars:
    my_list: [3, 1, 4, 1, 5, 9, 2, 6]
    my_string: "hello world"
    my_dict: {a: 1, b: 2, c: 3}

  tasks:
    - debug:
        msg:
          # Filtres de listes:
          sorted:    "{{ my_list | sort }}"           # [1, 1, 2, 3, 4, 5, 6, 9]
          unique:    "{{ my_list | unique }}"         # [3, 1, 4, 5, 9, 2, 6]
          min:       "{{ my_list | min }}"            # 1
          max:       "{{ my_list | max }}"            # 9
          sum:       "{{ my_list | sum }}"            # 31
          reversed:  "{{ my_list | reverse }}"        # [6, 2, 9, 5, 1, 4, 1, 3]
          first:     "{{ my_list | first }}"          # 3
          last:      "{{ my_list | last }}"           # 6
          length:    "{{ my_list | length }}"         # 8
          random:    "{{ my_list | random }}"         # élément aléatoire

          # Filtres de chaînes:
          upper:     "{{ my_string | upper }}"        # "HELLO WORLD"
          lower:     "{{ my_string | lower }}"        # "hello world"
          title:     "{{ my_string | title }}"        # "Hello World"
          replace:   "{{ my_string | replace(' ', '_') }}"  # "hello_world"
          split:     "{{ my_string | split(' ') }}"   # ["hello", "world"]
          trim:      "{{ '  spaces  ' | trim }}"      # "spaces"
          regex:     "{{ 'hello123' | regex_replace('[0-9]+', '') }}"  # "hello"

          # Filtres de dictionnaires:
          keys:      "{{ my_dict | dict2items | map(attribute='key') | list }}"  # ["a", "b", "c"]
          values:    "{{ my_dict.values() | list }}"  # [1, 2, 3]
          items2dict: "{{ [{'key': 'x', 'value': 1}] | items2dict }}"  # {x: 1}

          # Filtres réseau:
          ip_v4:     "{{ '192.168.1.0/24' | ipaddr('address') }}"    # "192.168.1.0"
          ip_prefix: "{{ '192.168.1.5' | ipaddr('192.168.1.0/24') }}"  # true/false

          # Filtres de crypto:
          sha256:    "{{ 'password123' | hash('sha256') }}"           # hash SHA256
          b64encode: "{{ 'hello' | b64encode }}"                      # "aGVsbG8="
          b64decode: "{{ 'aGVsbG8=' | b64decode }}"                   # "hello"
          password:  "{{ 'password' | password_hash('sha512') }}"     # hash Unix

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 9.10 — ANSIBLE INCLUDE ET IMPORT: MODULARISER LES PLAYBOOKS
────────────────────────────────────────────────────────────────────────────────

  DIFFÉRENCE ENTRE INCLUDE ET IMPORT:
  ──────────────────────────────────────
  import = chargé STATIQUEMENT au parsing (avant exécution)
           -> Les tags fonctionnent, les conditions when sont appliquées au groupe
  include = chargé DYNAMIQUEMENT à l'exécution
            -> Plus flexible (peut utiliser des variables dans le nom du fichier)
            -> Les tags doivent être ajoutés à la tâche include elle-même

  ── INCLUDE_TASKS: inclure des tâches dynamiquement ─────────────────────────

  # Dans site.yml ou un role:
  - name: Inclure les tâches spécifiques à l'OS
    include_tasks: "tasks/{{ ansible_distribution | lower }}.yml"
    # Si Ubuntu: charge tasks/ubuntu.yml
    # Si CentOS: charge tasks/centos.yml

  # Avec variables passées:
  - name: Déployer chaque microservice
    include_tasks: tasks/deploy_service.yml
    vars:
      service_name: "{{ item }}"
      service_port: "{{ services[item].port }}"
    loop:
      - api
      - worker
      - scheduler

  ── IMPORT_TASKS: importer des tâches statiquement ──────────────────────────

  # Dans un playbook:
  - hosts: web
    tasks:
      - import_tasks: tasks/common_setup.yml
        when: run_setup | default(true) | bool
        tags: [setup]

      - import_tasks: tasks/deploy_flask.yml
        tags: [deploy]

  ── INCLUDE_ROLE: inclure un role depuis une tâche ──────────────────────────

  # Utile pour inclure un role conditionnellement:
  - name: Configurer le monitoring si activé
    include_role:
      name: monitoring
    when: monitoring_enabled | bool

  # Avec des variables:
  - name: Configurer Nginx pour chaque vhost
    include_role:
      name: nginx
    vars:
      nginx_vhost_domain: "{{ item }}"
    loop:
      - api.equipe.com
      - admin.equipe.com

  ── IMPORT_PLAYBOOK: inclure un playbook depuis un autre ────────────────────

  # master.yml — Playbook qui orchestre tout:
  - import_playbook: infrastructure.yml   # Créer l'infrastructure Terraform
  - import_playbook: site.yml             # Configurer les serveurs
  - import_playbook: deploy.yml           # Déployer l'application

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 9.11 — ANSIBLE STRATEGY: CONTRÔLER L'ORDRE D'EXÉCUTION
────────────────────────────────────────────────────────────────────────────────

  STRATÉGIES ANSIBLE:
  ────────────────────

  # Strategy "linear" (défaut):
  # Ansible exécute TÂCHE 1 sur TOUS les hôtes, puis TÂCHE 2 sur TOUS, etc.
  # Synchronisé: un hôte lent bloque les autres pour chaque tâche.

  # Strategy "free":
  # Chaque hôte exécute les tâches à son propre rythme.
  # Le plus rapide avance sans attendre les autres.
  # Dangereux si les tâches ont des dépendances (migration DB avant restart app).

  # Strategy "host_pinned":
  # Exécute toutes les tâches sur 1 hôte, puis passe au suivant.
  # Comme un déploiement séquentiel.

  # Dans site.yml:
  - hosts: web
    strategy: free   # Les 3 serveurs configurent en parallèle
    tasks:
      - name: Installer les paquets apt
        apt:
          name: "{{ item }}"
        loop: "{{ common_packages }}"

  # SERIAL: contrôler combien d'hôtes sont traités simultanément
  - hosts: web
    serial: 1         # Un hôte à la fois (rolling deployment)
    # serial: 2       # 2 hôtes en parallèle
    # serial: "30%"   # 30% des hôtes en parallèle
    # serial: [1, 2, "100%"]  # D'abord 1, puis 2, puis tous (rampe progressive)
    tasks:
      - name: Déployer l'application
        # ...

  # MAX_FAIL_PERCENTAGE: accepter un % d'échecs avant d'arrêter
  - hosts: web
    max_fail_percentage: 30   # Arrêter si > 30% des hôtes échouent
    serial: 2
    tasks:
      # ...

================================================================================
  ████████╗███████╗██████╗ ██████╗  █████╗ ███████╗ ██████╗ ██████╗ ███╗   ███╗
     ██╔══╝██╔════╝██╔══██╗██╔══██╗██╔══██╗██╔════╝██╔═══██╗██╔══██╗████╗ ████║
     ██║   █████╗  ██████╔╝██████╔╝███████║█████╗  ██║   ██║██████╔╝██╔████╔██║
     ██║   ██╔══╝  ██╔══██╗██╔══██╗██╔══██║██╔══╝  ██║   ██║██╔══██╗██║╚██╔╝██║
     ██║   ███████╗██║  ██║██║  ██║██║  ██║██║     ╚██████╔╝██║  ██║██║ ╚═╝ ██║
     ╚═╝   ╚══════╝╚═╝  ╚═╝╚═╝  ╚═╝╚═╝  ╚═╝╚═╝      ╚═════╝ ╚═╝  ╚═╝╚═╝     ╚═╝
          ██╗  ██╗    █████╗ ███╗  ██╗███████╗██╗██████╗ ██╗     ███████╗
          ╚██╗██╔╝   ██╔══██╗████╗ ██║██╔════╝██║██╔══██╗██║     ██╔════╝
           ╚███╔╝    ███████║██╔██╗██║███████╗██║██████╔╝██║     █████╗
           ██╔██╗    ██╔══██║██║╚████║╚════██║██║██╔══██╗██║     ██╔══╝
          ██╔╝╚██╗   ██║  ██║██║ ╚███║███████║██║██████╔╝███████╗███████╗
          ╚═╝  ╚═╝   ╚═╝  ╚═╝╚═╝  ╚══╝╚══════╝╚═╝╚═════╝ ╚══════╝╚══════╝
================================================================================
  TERRAFORM & ANSIBLE — GUIDE SUITE (PARTIES 10 À 15)
  Application Flask TaskManager — Équipe de 3 développeurs
  ULTRA-DÉTAILLÉ POUR GRAND DÉBUTANT — ZÉRO DOCUMENTATION EXTERNE NÉCESSAIRE
  Toutes les fonctionnalités, tous les scénarios, tous les fichiers complets
================================================================================

[BLACK_RIGHT-POINTING_TRIANGLE] OÙ EN SOMMES-NOUS APRÈS LES PARTIES 1–9?

  Alice a:
  [OK] Installé Terraform + Ansible sur toutes les machines
  [OK] Créé l'infrastructure DigitalOcean (VPC, Firewall, Droplets, DNS, Volumes)
  [OK] Écrit 6 roles Ansible complets (common, postgresql, redis, flask_app, nginx, monitoring)
  [OK] Écrit les playbooks principaux (site.yml, deploy.yml, rollback.yml, maintenance.yml)
  [OK] Créé les secrets chiffrés avec Ansible Vault
  [OK] Déployé TaskManager API en staging ET en production

  CE QUI RESTE À COUVRIR:
  -> Partie 10: CI/CD GitHub Actions (automatiser Terraform + Ansible)
  -> Partie 11: Contenus complets de TOUS les fichiers de l'application Flask
  -> Partie 12: Sécurité avancée (tfsec, Ansible hardening, Fail2ban)
  -> Partie 13: Monitoring avancé (Grafana dashboards, alertes Prometheus)
  -> Partie 14: Debugging et troubleshooting (tout ce qui peut mal se passer)
  -> Partie 15: Flux de travail d'équipe complet jour par jour

================================================================================
PARTIE 10 — CI/CD AVEC GITHUB ACTIONS: AUTOMATISER TERRAFORM ET ANSIBLE
================================================================================

  POURQUOI CI/CD POUR L'INFRASTRUCTURE?
  ──────────────────────────────────────
  Sans CI/CD:
  -> Alice doit faire terraform apply et ansible-playbook MANUELLEMENT à chaque
    merge dans main. Elle peut oublier, se tromper de serveur, utiliser la
    mauvaise version.

  AVEC CI/CD GitHub Actions:
  -> Chaque PR vers main: GitHub Actions affiche le plan Terraform (comme un
    "diff infrastructure") dans la PR. L'équipe VOIT ce qui va changer avant
    de merger.
  -> Chaque merge dans develop: déploiement automatique sur staging.
  -> Chaque tag vX.Y.Z: déploiement automatique sur production.
  -> Alice, Bob et Claire peuvent déployer depuis n'importe quelle machine
    sans avoir Terraform/Ansible installé localement.

  ARCHITECTURE CI/CD SOUHAITÉE:
  ─────────────────────────────

  Git push -> PR ouverte sur main
       │
       ├── Workflow "terraform-plan.yml"
       │     -> terraform plan (staging + prod)
       │     -> Afficher le plan en commentaire dans la PR
       │     -> Bloquer le merge si plan échoue
       │
  PR mergée dans develop
       │
       └── Workflow "deploy-staging.yml"
             -> terraform apply (si infra a changé)
             -> ansible-playbook deploy.yml
             -> Tests d'intégration
             -> Notification Slack: "[OK] staging déployé"

  Tag v1.2.0 créé
       │
       └── Workflow "deploy-production.yml"
             -> Validation manuelle requise (environment protection)
             -> terraform apply
             -> ansible-playbook deploy.yml -e "app_version=v1.2.0"
             -> Health check
             -> Notification Slack: "[OK] production déployée"

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 10.1 — CONFIGURER LES SECRETS GITHUB ACTIONS
────────────────────────────────────────────────────────────────────────────────

  POURQUOI LES SECRETS GITHUB?
  ──────────────────────────────
  Les workflows GitHub Actions ont besoin de:
  - Le token DigitalOcean (pour Terraform)
  - La clé SSH (pour Ansible)
  - Le mot de passe du Vault Ansible
  - Le token Terraform Cloud
  Ces valeurs ne doivent JAMAIS être dans les fichiers .yml du workflow.
  GitHub Secrets les stocke chiffrés et les injecte dans les workflows.

  OÙ AJOUTER LES SECRETS?
  ────────────────────────
  1. Aller sur: github.com/equipe/taskmanager
  2. Settings -> Secrets and variables -> Actions -> New repository secret

  SECRETS À CRÉER (un par un):
  ──────────────────────────────

  Name: DO_API_TOKEN
  Value: dop_v1_votre_token_digitalocean_avec_droits_readwrite
  Pourquoi: Terraform en a besoin pour créer/gérer les ressources DigitalOcean.

  Name: SSH_PRIVATE_KEY
  Value: (contenu complet de ~/.ssh/id_ed25519_github, y compris -----BEGIN et -----END)
  Pourquoi: Ansible utilise cette clé pour se connecter aux serveurs via SSH.
  Comment copier: cat ~/.ssh/id_ed25519_github | pbcopy (Mac) ou xclip (Linux)

  Name: ANSIBLE_VAULT_PASSWORD_STAGING
  Value: le-mot-de-passe-du-vault-staging
  Pourquoi: Ansible doit déchiffrer vars/staging_secrets.yml pendant le déploiement.

  Name: ANSIBLE_VAULT_PASSWORD_PROD
  Value: le-mot-de-passe-du-vault-production
  Pourquoi: Même chose pour la production.

  Name: TF_API_TOKEN
  Value: (token généré sur app.terraform.io -> User Settings -> Tokens)
  Pourquoi: Pour que GitHub Actions puisse utiliser Terraform Cloud comme backend.

  Name: SLACK_WEBHOOK_URL
  Value: https://hooks.slack.com/services/T.../B.../xxx
  Pourquoi: Envoyer des notifications dans le canal #deployments de Slack.

  CRÉER UN ENVIRONMENT "PRODUCTION" AVEC PROTECTION:
  ────────────────────────────────────────────────────
  POURQUOI? Les déploiements en production nécessitent une validation humaine.
  GitHub Environments permettent de bloquer un workflow jusqu'à qu'un responsable
  clique "Approve" dans l'interface web.

  1. Settings -> Environments -> New environment -> Nom: "production"
  2. Cocher: "Required reviewers" -> Ajouter Alice comme reviewer obligatoire
  3. Cocher: "Wait timer" -> 5 minutes (délai de grâce avant déploiement auto)
  4. Ajouter les secrets spécifiques à la production dans cet environment.

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 10.2 — WORKFLOW: VÉRIFICATION SUR CHAQUE PR (.github/workflows/ci.yml)
────────────────────────────────────────────────────────────────────────────────

  POURQUOI CE WORKFLOW?
  ──────────────────────
  Ce workflow s'exécute sur CHAQUE pull request.
  Il vérifie: syntaxe Terraform, lint Ansible, tests Python, et affiche le plan.
  L'équipe voit exactement ce qui va changer AVANT de merger.

  QUAND S'EXÉCUTE-T-IL?
  ──────────────────────
  -> À l'ouverture d'une PR
  -> À chaque nouveau commit dans la PR
  -> Sur les branches: feature/*, fix/*, develop

  ALICE CRÉE LE FICHIER:
  ──────────────────────
  mkdir -p .github/workflows
  nano .github/workflows/ci.yml

  Contenu complet:

  # ═══════════════════════════════════════════════════════════════════════════
  # ci.yml — Intégration continue: vérifications sur chaque Pull Request
  # ═══════════════════════════════════════════════════════════════════════════
  name: CI — Vérifications Pull Request

  # DÉCLENCHEURS: quand ce workflow s'exécute
  on:
    pull_request:
      branches:
        - main
        - develop
      # paths: limiter aux fichiers concernés (optimisation):
      paths:
        - 'app/**'
        - 'tests/**'
        - 'infrastructure/**'
        - 'requirements*.txt'
        - 'Dockerfile'
        - '.github/workflows/**'

  # PERMISSIONS: droits nécessaires pour commenter sur la PR
  permissions:
    contents: read
    pull-requests: write   # Pour poster le plan Terraform en commentaire
    issues: write

  # VARIABLES D'ENVIRONNEMENT GLOBALES (disponibles dans tous les jobs)
  env:
    PYTHON_VERSION: "3.11"
    TF_VERSION: "1.7.0"
    ANSIBLE_VERSION: "9.0.0"
    TF_TOKEN_app_terraform_io: ${{ secrets.TF_API_TOKEN }}

  jobs:

    # ─────────────────────────────────────────────────────────────────────────
    # JOB 1: Tests Python (pytest)
    # ─────────────────────────────────────────────────────────────────────────
    python-tests:
      name: "[PYTHON] Tests Python + Coverage"
      runs-on: ubuntu-22.04

      # Services Docker: démarrer une vraie DB PostgreSQL et Redis pour les tests
      services:
        postgres:
          image: postgres:15-alpine
          env:
            POSTGRES_DB: taskmanager_test
            POSTGRES_USER: taskmanager
            POSTGRES_PASSWORD: test_password_ci
          ports:
            - 5432:5432
          options: >-
            --health-cmd pg_isready
            --health-interval 10s
            --health-timeout 5s
            --health-retries 5
            # options health: attendre que PostgreSQL soit prêt avant de continuer

        redis:
          image: redis:7-alpine
          ports:
            - 6379:6379
          options: >-
            --health-cmd "redis-cli ping"
            --health-interval 10s
            --health-timeout 5s
            --health-retries 5

      steps:
        # Étape 1: Récupérer le code
        - name: [ENTREE] Checkout du code
          uses: actions/checkout@v4
          with:
            fetch-depth: 0   # Historique complet (nécessaire pour certaines vérifications)

        # Étape 2: Configurer Python avec cache
        - name: [PYTHON] Configurer Python ${{ env.PYTHON_VERSION }}
          uses: actions/setup-python@v5
          with:
            python-version: ${{ env.PYTHON_VERSION }}
            cache: 'pip'   # Cache le dossier pip pour accélérer les installations
            cache-dependency-path: requirements*.txt

        # Étape 3: Installer les dépendances
        - name: [PACKAGE] Installer les dépendances Python
          run: |
            python -m pip install --upgrade pip
            pip install -r requirements.txt
            pip install -r requirements-dev.txt
            # requirements-dev.txt contient: pytest, pytest-cov, pytest-flask, flake8...

        # Étape 4: Lint avec flake8
        - name: [RECHERCHE] Lint Python (flake8)
          run: |
            flake8 app/ tests/ \
              --max-line-length=120 \
              --exclude=app/__pycache__,*.pyc \
              --ignore=E501,W503
          # E501 = line too long (désactivé car annoying)
          # W503 = line break before binary operator (style personnel)

        # Étape 5: Vérifier le formatage avec black
        - name: [DESIGN] Vérifier le formatage (black)
          run: |
            pip install black
            black --check app/ tests/
          # black --check = vérifie sans modifier (retourne 1 si changements nécessaires)

        # Étape 6: Exécuter les tests avec couverture de code
        - name: [TEST] Exécuter les tests pytest
          env:
            FLASK_ENV: testing
            FLASK_DEBUG: "0"
            DATABASE_URL: postgresql://taskmanager:test_password_ci@localhost:5432/taskmanager_test
            REDIS_URL: redis://localhost:6379/1
            SECRET_KEY: ci-test-secret-key-not-real
          run: |
            pytest tests/ \
              --verbose \
              --tb=short \
              --cov=app \
              --cov-report=xml:coverage.xml \
              --cov-report=html:coverage_html \
              --cov-report=term-missing \
              --cov-fail-under=75 \
              --junit-xml=test-results.xml
            # --cov-fail-under=75: échouer si couverture < 75%
            # --junit-xml: rapport XML lisible par GitHub Actions

        # Étape 7: Publier les résultats des tests
        - name: [GRAPHIQUE] Publier les résultats des tests
          uses: dorny/test-reporter@v1
          if: always()   # Toujours exécuter, même si les tests échouent
          with:
            name: Résultats pytest
            path: test-results.xml
            reporter: java-junit
            fail-on-error: true

        # Étape 8: Upload le rapport de couverture vers Codecov (optionnel)
        - name: [HAUSSE] Upload coverage vers Codecov
          uses: codecov/codecov-action@v4
          if: always()
          with:
            file: coverage.xml
            fail_ci_if_error: false   # Ne pas bloquer la CI si Codecov est en panne

    # ─────────────────────────────────────────────────────────────────────────
    # JOB 2: Vérification Terraform
    # ─────────────────────────────────────────────────────────────────────────
    terraform-check:
      name: "[CONSTRUCTION] Terraform Lint + Validate + Plan"
      runs-on: ubuntu-22.04
      strategy:
        matrix:
          environment: [staging, production]
          # Exécuter ce job en parallèle pour staging ET production
          # Les deux colonnes apparaissent dans la PR

      steps:
        - name: [ENTREE] Checkout du code
          uses: actions/checkout@v4

        # Installer Terraform (version fixée = reproductibilité)
        - name: [OUTIL] Installer Terraform ${{ env.TF_VERSION }}
          uses: hashicorp/setup-terraform@v3
          with:
            terraform_version: ${{ env.TF_VERSION }}
            # cli_config_credentials_token est automatiquement lu depuis
            # TF_TOKEN_app_terraform_io (défini dans env global)

        # Configurer le token DigitalOcean
        - name: [CLE] Configurer les credentials
          run: |
            echo "TF_VAR_do_token=${{ secrets.DO_API_TOKEN }}" >> $GITHUB_ENV

        # terraform fmt --check: vérifier le formatage HCL
        - name: [DESIGN] Terraform fmt (vérifier formatage)
          run: |
            terraform fmt -check -recursive infrastructure/terraform/
          # -check = retourner 1 si des fichiers sont mal formatés
          # -recursive = vérifier tous les sous-dossiers

        # terraform init
        - name: [RAPIDE] Terraform Init (${{ matrix.environment }})
          run: |
            cd infrastructure/terraform/environments/${{ matrix.environment }}
            terraform init -backend-config="token=${{ secrets.TF_API_TOKEN }}"

        # terraform validate: vérifier la syntaxe
        - name: [OK] Terraform Validate (${{ matrix.environment }})
          run: |
            cd infrastructure/terraform/environments/${{ matrix.environment }}
            terraform validate

        # tflint: linter avancé
        - name: [RECHERCHE] TFLint (${{ matrix.environment }})
          uses: terraform-linters/setup-tflint@v4
          with:
            tflint_version: v0.50.0
        - run: |
            cd infrastructure/terraform/environments/${{ matrix.environment }}
            tflint --init
            tflint --format compact

        # tfsec: scanner de sécurité
        - name: [VERROUILLE] tfsec (scan sécurité Terraform)
          uses: aquasecurity/tfsec-action@v1.0.0
          with:
            working_directory: infrastructure/terraform/environments/${{ matrix.environment }}
            soft_fail: true   # Ne pas bloquer la CI (juste avertir)

        # terraform plan: générer le plan et l'afficher dans la PR
        - name: [LISTE] Terraform Plan (${{ matrix.environment }})
          id: plan
          run: |
            cd infrastructure/terraform/environments/${{ matrix.environment }}
            terraform plan -no-color -out=tfplan 2>&1 | tee plan_output.txt
            echo "exitcode=$?" >> $GITHUB_OUTPUT
          continue-on-error: true   # Ne pas bloquer si plan a des erreurs
          env:
            TF_VAR_do_token: ${{ secrets.DO_API_TOKEN }}

        # Poster le plan en commentaire dans la PR
        - name: [SPEECH_BALLOON] Commenter le Plan dans la PR
          uses: actions/github-script@v7
          if: github.event_name == 'pull_request'
          env:
            PLAN: ${{ steps.plan.outputs.stdout }}
          with:
            github-token: ${{ secrets.GITHUB_TOKEN }}
            script: |
              const fs = require('fs');
              const planOutput = fs.readFileSync(
                'infrastructure/terraform/environments/${{ matrix.environment }}/plan_output.txt',
                'utf8'
              );
              const output = `## [CONSTRUCTION] Terraform Plan — ${{ matrix.environment }}

              \`\`\`terraform
              ${planOutput.substring(0, 65000)}
              \`\`\`

              *Workflow: \`${{ github.workflow }}\` — Commit: \`${{ github.sha }}\`*`;

              // Mettre à jour ou créer le commentaire
              const { data: comments } = await github.rest.issues.listComments({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
              });

              const botComment = comments.find(comment =>
                comment.user.type === 'Bot' &&
                comment.body.includes('Terraform Plan — ${{ matrix.environment }}')
              );

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

    # ─────────────────────────────────────────────────────────────────────────
    # JOB 3: Vérification Ansible
    # ─────────────────────────────────────────────────────────────────────────
    ansible-lint:
      name: "[DOC] Ansible Lint + Syntax Check"
      runs-on: ubuntu-22.04

      steps:
        - name: [ENTREE] Checkout du code
          uses: actions/checkout@v4

        - name: [PYTHON] Configurer Python
          uses: actions/setup-python@v5
          with:
            python-version: ${{ env.PYTHON_VERSION }}
            cache: 'pip'

        - name: [PACKAGE] Installer Ansible + ansible-lint
          run: |
            pip install ansible==${{ env.ANSIBLE_VERSION }} ansible-lint

        # ansible-lint: vérifier les bonnes pratiques Ansible
        - name: [RECHERCHE] Ansible Lint
          run: |
            cd infrastructure/ansible
            ansible-lint \
              --profile=production \
              --exclude=roles/monitoring \
              site.yml deploy.yml rollback.yml
          # --profile=production: règles strictes
          # Génère une erreur si les playbooks ne respectent pas les bonnes pratiques

        # ansible-playbook --syntax-check: vérifier la syntaxe YAML
        - name: [OK] Ansible Syntax Check
          run: |
            cd infrastructure/ansible
            # Utiliser un inventaire fictif pour le check de syntaxe
            echo "[all]
            localhost ansible_connection=local" > /tmp/test_inventory.ini

            ansible-playbook -i /tmp/test_inventory.ini site.yml --syntax-check
            ansible-playbook -i /tmp/test_inventory.ini deploy.yml --syntax-check
            ansible-playbook -i /tmp/test_inventory.ini rollback.yml --syntax-check

        # Vérifier que les fichiers vault sont bien chiffrés
        - name: [VERROUILLE] Vérifier que les fichiers secrets sont chiffrés
          run: |
            for f in infrastructure/ansible/vars/*.yml; do
              # Chaque fichier vars doit commencer par $ANSIBLE_VAULT
              head -1 "$f" | grep -q '^\$ANSIBLE_VAULT' || {
                echo "[X] ERREUR: $f n'est PAS chiffré avec Ansible Vault!"
                echo "Commande pour le chiffrer: ansible-vault encrypt $f"
                exit 1
              }
            done
            echo "[OK] Tous les fichiers secrets sont correctement chiffrés"

    # ─────────────────────────────────────────────────────────────────────────
    # JOB 4: Vérification de sécurité globale
    # ─────────────────────────────────────────────────────────────────────────
    security-scan:
      name: "[VERROUILLE] Scan Sécurité (Checkov + Trivy)"
      runs-on: ubuntu-22.04

      steps:
        - name: [ENTREE] Checkout du code
          uses: actions/checkout@v4

        # Checkov: scanner multi-outils (Terraform, Dockerfile, Ansible)
        - name: [VERROUILLE] Checkov Scan
          uses: bridgecrewio/checkov-action@master
          with:
            directory: infrastructure/
            framework: terraform,ansible
            soft_fail: true
            output_format: sarif
            output_file_path: results/checkov.sarif

        # Trivy: scanner de vulnérabilités pour Dockerfile et images
        - name: [DOCKER] Trivy Scan (Dockerfile + dépendances)
          uses: aquasecurity/trivy-action@master
          with:
            scan-type: 'fs'
            scan-ref: '.'
            format: 'table'
            exit-code: '0'   # Ne pas bloquer (soft fail)
            severity: 'CRITICAL,HIGH'

        # Upload les résultats SARIF vers GitHub Security
        - name: [SORTIE] Upload résultats Checkov vers GitHub Security
          uses: github/codeql-action/upload-sarif@v3
          if: always()
          with:
            sarif_file: results/checkov.sarif

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 10.3 — WORKFLOW: DÉPLOIEMENT AUTOMATIQUE EN STAGING
           (.github/workflows/deploy-staging.yml)
────────────────────────────────────────────────────────────────────────────────

  POURQUOI UN WORKFLOW SÉPARÉ POUR STAGING?
  ───────────────────────────────────────────
  Le déploiement staging = automatique. Chaque merge dans develop déclenche
  le déploiement sans intervention humaine.
  C'est différent du CI (qui vérifie) et du déploiement prod (qui requiert approbation).

  nano .github/workflows/deploy-staging.yml

  Contenu complet:

  # ═══════════════════════════════════════════════════════════════════════════
  # deploy-staging.yml — Déploiement automatique en staging sur merge dans develop
  # ═══════════════════════════════════════════════════════════════════════════
  name: "[RAPIDE] Deploy Staging"

  on:
    push:
      branches:
        - develop
    # Permettre le déclenchement manuel depuis GitHub UI:
    workflow_dispatch:
      inputs:
        app_version:
          description: "Version à déployer (branche, tag, ou hash)"
          required: false
          default: "develop"
        force_infra:
          description: "Forcer terraform apply même sans changements .tf?"
          type: boolean
          required: false
          default: false

  env:
    TF_TOKEN_app_terraform_io: ${{ secrets.TF_API_TOKEN }}
    ENVIRONMENT: staging
    APP_VERSION: ${{ github.event.inputs.app_version || 'develop' }}

  jobs:

    # ─────────────────────────────────────────────────────────────────────────
    # JOB 1: Terraform Apply Staging (si fichiers .tf ont changé)
    # ─────────────────────────────────────────────────────────────────────────
    terraform-staging:
      name: "[CONSTRUCTION] Terraform Staging"
      runs-on: ubuntu-22.04
      outputs:
        # Exporter l'IP du serveur staging pour le job Ansible suivant
        staging_ip: ${{ steps.tf-output.outputs.staging_ip }}

      steps:
        - name: [ENTREE] Checkout
          uses: actions/checkout@v4

        # Détecter si des fichiers Terraform ont changé
        - name: [RECHERCHE] Détecter changements Terraform
          uses: dorny/paths-filter@v3
          id: changes
          with:
            filters: |
              terraform:
                - 'infrastructure/terraform/**'

        - name: [OUTIL] Installer Terraform
          if: steps.changes.outputs.terraform == 'true' || github.event.inputs.force_infra == 'true'
          uses: hashicorp/setup-terraform@v3
          with:
            terraform_version: "1.7.0"
            terraform_wrapper: false
            # terraform_wrapper: false = ne pas envelopper les outputs (plus de contrôle)

        - name: [RAPIDE] Terraform Init
          if: steps.changes.outputs.terraform == 'true' || github.event.inputs.force_infra == 'true'
          run: |
            cd infrastructure/terraform/environments/staging
            terraform init
          env:
            TF_VAR_do_token: ${{ secrets.DO_API_TOKEN }}

        - name: [LISTE] Terraform Plan
          if: steps.changes.outputs.terraform == 'true' || github.event.inputs.force_infra == 'true'
          run: |
            cd infrastructure/terraform/environments/staging
            terraform plan -out=staging.tfplan
          env:
            TF_VAR_do_token: ${{ secrets.DO_API_TOKEN }}

        - name: [OK] Terraform Apply
          if: steps.changes.outputs.terraform == 'true' || github.event.inputs.force_infra == 'true'
          run: |
            cd infrastructure/terraform/environments/staging
            terraform apply -auto-approve staging.tfplan
          env:
            TF_VAR_do_token: ${{ secrets.DO_API_TOKEN }}

        # Récupérer l'IP du serveur staging depuis les outputs Terraform
        - name: [SORTIE] Récupérer les outputs Terraform
          id: tf-output
          run: |
            cd infrastructure/terraform/environments/staging
            terraform init -backend-only 2>/dev/null || true
            STAGING_IP=$(terraform output -raw staging_ip 2>/dev/null || echo "")
            echo "staging_ip=${STAGING_IP}" >> $GITHUB_OUTPUT
            echo "IP staging: ${STAGING_IP}"
          env:
            TF_VAR_do_token: ${{ secrets.DO_API_TOKEN }}

    # ─────────────────────────────────────────────────────────────────────────
    # JOB 2: Ansible Deploy Staging
    # ─────────────────────────────────────────────────────────────────────────
    ansible-staging:
      name: "[DOC] Ansible Deploy Staging"
      runs-on: ubuntu-22.04
      needs: terraform-staging   # Attendre que terraform-staging soit terminé
      environment:
        name: staging
        url: http://${{ needs.terraform-staging.outputs.staging_ip }}/health

      steps:
        - name: [ENTREE] Checkout
          uses: actions/checkout@v4

        - name: [PYTHON] Configurer Python + Ansible
          uses: actions/setup-python@v5
          with:
            python-version: "3.11"
            cache: 'pip'
        - run: pip install ansible==9.0.0

        # Configurer la clé SSH pour la connexion aux serveurs
        - name: [CLE] Configurer la clé SSH
          run: |
            mkdir -p ~/.ssh
            echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
            chmod 600 ~/.ssh/deploy_key
            # Ajouter l'hôte aux known_hosts (évite la question "Are you sure?")
            ssh-keyscan -H ${{ needs.terraform-staging.outputs.staging_ip }} >> ~/.ssh/known_hosts 2>/dev/null || true
            echo "IdentityFile ~/.ssh/deploy_key" >> ~/.ssh/config
            echo "StrictHostKeyChecking no" >> ~/.ssh/config
          # Note: StrictHostKeyChecking no est acceptable en CI
          # (les serveurs sont recréés par Terraform avec des fingerprints changeantes)
          # En prod manuelle: on préfère yes

        # Créer le fichier de mot de passe Vault
        - name: [VERROUILLE] Configurer Ansible Vault
          run: |
            echo "${{ secrets.ANSIBLE_VAULT_PASSWORD_STAGING }}" > /tmp/vault_pass
            chmod 600 /tmp/vault_pass

        # Mettre à jour l'inventaire avec l'IP actuelle du serveur
        - name: [NOTE] Mettre à jour l'inventaire staging
          run: |
            STAGING_IP="${{ needs.terraform-staging.outputs.staging_ip }}"
            if [ -n "$STAGING_IP" ]; then
              cat > infrastructure/ansible/inventory/staging.ini << EOF
            [web]
            taskmanager-staging ansible_host=${STAGING_IP}

            [db]
            taskmanager-staging ansible_host=${STAGING_IP}

            [staging:children]
            web
            db

            [all:vars]
            ansible_user=ubuntu
            ansible_python_interpreter=/usr/bin/python3
            EOF
              echo "Inventaire mis à jour avec l'IP: ${STAGING_IP}"
            else
              echo "Utilisation de l'inventaire existant (IP inchangée)"
            fi

        # Vérifier la connectivité SSH avant de lancer Ansible
        - name: [LIEN] Vérifier la connectivité SSH
          run: |
            STAGING_IP="${{ needs.terraform-staging.outputs.staging_ip }}"
            if [ -n "$STAGING_IP" ]; then
              # Attendre jusqu'à 5 minutes que le serveur accepte SSH
              for i in $(seq 1 30); do
                if ssh -o ConnectTimeout=5 -o BatchMode=yes ubuntu@${STAGING_IP} echo "SSH OK" 2>/dev/null; then
                  echo "[OK] SSH disponible sur ${STAGING_IP}"
                  break
                fi
                echo "Tentative $i/30... attente 10s"
                sleep 10
              done
            fi

        # Ansible Ping: vérifier que Ansible peut se connecter
        - name: [RESEAU] Ansible Ping
          run: |
            cd infrastructure/ansible
            ansible -i inventory/staging.ini all \
              --private-key ~/.ssh/deploy_key \
              -m ping \
              --vault-password-file /tmp/vault_pass

        # Si c'est le premier déploiement (nouveau serveur): site.yml complet
        # Sinon: deploy.yml (code seulement)
        - name: [RECHERCHE] Détecter si première installation ou mise à jour
          id: detect-deploy-type
          run: |
            STAGING_IP="${{ needs.terraform-staging.outputs.staging_ip }}"
            # Vérifier si Flask est déjà installé sur le serveur
            if ssh -o BatchMode=yes ubuntu@${STAGING_IP} \
                 "systemctl is-active taskmanager > /dev/null 2>&1" 2>/dev/null; then
              echo "deploy_type=update" >> $GITHUB_OUTPUT
              echo "[SYNC] Flask déjà installé -> déploiement rapide (deploy.yml)"
            else
              echo "deploy_type=full" >> $GITHUB_OUTPUT
              echo "🆕 Première installation -> déploiement complet (site.yml)"
            fi

        # Déploiement complet (première fois)
        - name: [OBJECTIF] Ansible site.yml (première installation)
          if: steps.detect-deploy-type.outputs.deploy_type == 'full'
          run: |
            cd infrastructure/ansible
            ansible-playbook \
              -i inventory/staging.ini \
              --private-key ~/.ssh/deploy_key \
              --vault-password-file /tmp/vault_pass \
              -e "app_version=${{ env.APP_VERSION }}" \
              site.yml
          env:
            ANSIBLE_HOST_KEY_CHECKING: "False"
            ANSIBLE_FORCE_COLOR: "1"

        # Déploiement rapide (mises à jour)
        - name: [RAPIDE] Ansible deploy.yml (mise à jour)
          if: steps.detect-deploy-type.outputs.deploy_type == 'update'
          run: |
            cd infrastructure/ansible
            ansible-playbook \
              -i inventory/staging.ini \
              --private-key ~/.ssh/deploy_key \
              --vault-password-file /tmp/vault_pass \
              -e "app_version=${{ env.APP_VERSION }}" \
              deploy.yml
          env:
            ANSIBLE_HOST_KEY_CHECKING: "False"
            ANSIBLE_FORCE_COLOR: "1"

        # Tests d'intégration post-déploiement
        - name: [TEST] Tests d'intégration staging
          run: |
            STAGING_IP="${{ needs.terraform-staging.outputs.staging_ip }}"
            BASE_URL="http://${STAGING_IP}"
            echo "Test de l'API sur ${BASE_URL}"

            # Test 1: Health check
            response=$(curl -sf "${BASE_URL}/health" || echo "FAILED")
            echo "Health check: $response"
            echo "$response" | grep -q '"status":"healthy"' || {
              echo "[X] Health check échoué!"
              exit 1
            }

            # Test 2: Créer une tâche
            task=$(curl -sf -X POST "${BASE_URL}/tasks" \
              -H "Content-Type: application/json" \
              -d '{"title": "CI Test Task", "priority": "low"}' || echo "FAILED")
            echo "Création tâche: $task"
            TASK_ID=$(echo $task | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null)

            # Test 3: Lire la tâche créée
            if [ -n "$TASK_ID" ]; then
              get_task=$(curl -sf "${BASE_URL}/tasks/${TASK_ID}" || echo "FAILED")
              echo "Lecture tâche $TASK_ID: $get_task"
              echo "$get_task" | grep -q "CI Test Task" || {
                echo "[X] Lecture de tâche échouée!"
                exit 1
              }
            fi

            # Test 4: Vérifier les performances (temps de réponse < 500ms)
            time_ms=$(curl -sf -o /dev/null -w "%{time_total}" "${BASE_URL}/tasks" | \
              awk '{printf "%.0f", $1 * 1000}')
            echo "Temps de réponse: ${time_ms}ms"
            [ "$time_ms" -lt 500 ] || {
              echo "[ATTENTION] Avertissement: temps de réponse élevé (${time_ms}ms)"
            }

            echo "[OK] Tous les tests d'intégration ont réussi!"

        # Nettoyer les secrets
        - name: [NETTOYAGE] Nettoyer les secrets
          if: always()
          run: |
            rm -f /tmp/vault_pass
            rm -f ~/.ssh/deploy_key
            echo "Secrets nettoyés"

    # ─────────────────────────────────────────────────────────────────────────
    # JOB 3: Notification finale
    # ─────────────────────────────────────────────────────────────────────────
    notify-staging:
      name: "[ANNONCE] Notification Staging"
      runs-on: ubuntu-22.04
      needs: [terraform-staging, ansible-staging]
      if: always()   # Toujours notifier, même en cas d'échec

      steps:
        - name: [ANNONCE] Notification Slack
          uses: 8398a7/action-slack@v3
          with:
            status: ${{ job.status }}
            fields: repo,message,commit,author,action,took
            text: |
              ${{ needs.ansible-staging.result == 'success' && '[OK]' || '[X]' }}
              *Déploiement STAGING*
              Version: `${{ env.APP_VERSION }}`
              Commit: `${{ github.sha }}`
              Par: ${{ github.actor }}
              URL: http://${{ needs.terraform-staging.outputs.staging_ip }}/health
          env:
            SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 10.4 — WORKFLOW: DÉPLOIEMENT EN PRODUCTION
           (.github/workflows/deploy-production.yml)
────────────────────────────────────────────────────────────────────────────────

  DIFFÉRENCE PROD vs STAGING:
  ────────────────────────────
  -> Déclenché par un TAG Git (v1.0.0, v1.1.0...) et NON par un push de branche
  -> Requiert une approbation manuelle d'Alice (environment protection)
  -> Délai de grâce de 5 minutes avant déploiement
  -> Vérification plus stricte post-déploiement
  -> Rollback automatique si le health check échoue après déploiement

  nano .github/workflows/deploy-production.yml

  Contenu:

  # ═══════════════════════════════════════════════════════════════════════════
  # deploy-production.yml — Déploiement en production sur création d'un tag
  # ═══════════════════════════════════════════════════════════════════════════
  name: "[RAPIDE] Deploy Production"

  on:
    push:
      tags:
        - 'v[0-9]+.[0-9]+.[0-9]+'   # Déclenché sur les tags type v1.2.3
        # Regex: v suivi de 3 groupes de chiffres séparés par des points
    workflow_dispatch:
      inputs:
        tag_version:
          description: "Tag/version à déployer (ex: v1.2.0)"
          required: true

  env:
    TF_TOKEN_app_terraform_io: ${{ secrets.TF_API_TOKEN }}
    APP_VERSION: ${{ github.ref_name || github.event.inputs.tag_version }}

  jobs:

    # Récupérer l'IP production depuis Terraform (sans apply)
    get-production-ip:
      name: "[IMPORTANT] Récupérer IP Production"
      runs-on: ubuntu-22.04
      outputs:
        production_ip: ${{ steps.get-ip.outputs.production_ip }}
      steps:
        - uses: actions/checkout@v4
        - uses: hashicorp/setup-terraform@v3
          with:
            terraform_version: "1.7.0"
            terraform_wrapper: false
        - id: get-ip
          run: |
            cd infrastructure/terraform/environments/production
            terraform init -backend-only 2>/dev/null || true
            PROD_IP=$(terraform output -raw production_ip 2>/dev/null || echo "")
            echo "production_ip=${PROD_IP}" >> $GITHUB_OUTPUT
          env:
            TF_VAR_do_token: ${{ secrets.DO_API_TOKEN }}

    # Déploiement avec approbation manuelle
    deploy-production:
      name: "[RAPIDE] Deploy Production ${{ env.APP_VERSION }}"
      runs-on: ubuntu-22.04
      needs: get-production-ip
      environment:
        name: production   # Requiert l'approbation d'Alice (configuré dans GitHub Settings)
        url: https://api.equipe.com/health

      steps:
        - uses: actions/checkout@v4
          with:
            ref: ${{ env.APP_VERSION }}   # Checkout le tag exact

        - name: [PYTHON] Configurer Python + Ansible
          uses: actions/setup-python@v5
          with:
            python-version: "3.11"
        - run: pip install ansible==9.0.0

        - name: [CLE] Configurer SSH
          run: |
            mkdir -p ~/.ssh
            echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
            chmod 600 ~/.ssh/deploy_key
            PROD_IP="${{ needs.get-production-ip.outputs.production_ip }}"
            ssh-keyscan -H ${PROD_IP} >> ~/.ssh/known_hosts 2>/dev/null || true

        - name: [VERROUILLE] Configurer Ansible Vault (production)
          run: |
            echo "${{ secrets.ANSIBLE_VAULT_PASSWORD_PROD }}" > /tmp/vault_pass_prod
            chmod 600 /tmp/vault_pass_prod

        - name: [NOTE] Mettre à jour l'inventaire production
          run: |
            PROD_IP="${{ needs.get-production-ip.outputs.production_ip }}"
            cat > infrastructure/ansible/inventory/production.ini << EOF
          [web]
          taskmanager-prod ansible_host=${PROD_IP}

          [db]
          taskmanager-prod ansible_host=${PROD_IP}

          [production:children]
          web
          db

          [all:vars]
          ansible_user=ubuntu
          ansible_python_interpreter=/usr/bin/python3
          EOF

        # Sauvegarder la DB avant déploiement
        - name: [SAUVEGARDE] Sauvegarder la base de données production
          run: |
            cd infrastructure/ansible
            ansible-playbook \
              -i inventory/production.ini \
              --private-key ~/.ssh/deploy_key \
              --vault-password-file /tmp/vault_pass_prod \
              maintenance.yml \
              --tags backup
          env:
            ANSIBLE_HOST_KEY_CHECKING: "False"

        # Déployer la nouvelle version
        - name: [RAPIDE] Déployer ${{ env.APP_VERSION }} en production
          id: deploy
          run: |
            cd infrastructure/ansible
            ansible-playbook \
              -i inventory/production.ini \
              --private-key ~/.ssh/deploy_key \
              --vault-password-file /tmp/vault_pass_prod \
              -e "app_version=${{ env.APP_VERSION }}" \
              deploy.yml
          env:
            ANSIBLE_HOST_KEY_CHECKING: "False"
            ANSIBLE_FORCE_COLOR: "1"

        # Health check post-déploiement
        - name: [HOPITAL] Health Check Post-Déploiement
          id: health-check
          run: |
            PROD_IP="${{ needs.get-production-ip.outputs.production_ip }}"
            for i in $(seq 1 10); do
              response=$(curl -sf "https://api.equipe.com/health" 2>/dev/null || \
                         curl -sf "http://${PROD_IP}/health" 2>/dev/null || echo "")
              if echo "$response" | grep -q '"status":"healthy"'; then
                echo "[OK] Production en bonne santé!"
                echo "health_status=healthy" >> $GITHUB_OUTPUT
                exit 0
              fi
              echo "Tentative $i/10... ($response)"
              sleep 15
            done
            echo "health_status=unhealthy" >> $GITHUB_OUTPUT
            exit 1

        # Rollback automatique si le health check échoue
        - name: [SYNC] Rollback automatique si santé KO
          if: failure() && steps.deploy.outcome == 'success'
          run: |
            echo "[X] Health check échoué! Rollback en cours..."
            cd infrastructure/ansible
            ansible-playbook \
              -i inventory/production.ini \
              --private-key ~/.ssh/deploy_key \
              --vault-password-file /tmp/vault_pass_prod \
              -e "skip_confirmation=true" \
              rollback.yml
          env:
            ANSIBLE_HOST_KEY_CHECKING: "False"

        - name: [NETTOYAGE] Nettoyer les secrets
          if: always()
          run: |
            rm -f /tmp/vault_pass_prod ~/.ssh/deploy_key

    # Créer une GitHub Release après le déploiement réussi
    create-release:
      name: "[BRAVO] Créer GitHub Release"
      runs-on: ubuntu-22.04
      needs: deploy-production
      if: success()

      steps:
        - uses: actions/checkout@v4
          with:
            fetch-depth: 0
        - name: [LISTE] Générer les notes de release
          id: generate-notes
          run: |
            # Récupérer les commits depuis le tag précédent
            PREV_TAG=$(git tag --sort=-v:refname | sed -n '2p' || echo "")
            if [ -n "$PREV_TAG" ]; then
              COMMITS=$(git log ${PREV_TAG}..HEAD --oneline --no-merges)
            else
              COMMITS=$(git log --oneline --no-merges -20)
            fi
            echo "commits<<EOF" >> $GITHUB_OUTPUT
            echo "$COMMITS" >> $GITHUB_OUTPUT
            echo "EOF" >> $GITHUB_OUTPUT

        - name: [BRAVO] Créer la Release GitHub
          uses: actions/create-release@v1
          env:
            GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          with:
            tag_name: ${{ env.APP_VERSION }}
            release_name: "Release ${{ env.APP_VERSION }}"
            body: |
              ## [RAPIDE] Déploiement Production — ${{ env.APP_VERSION }}

              **Déployé par:** ${{ github.actor }}
              **Date:** ${{ github.event.head_commit.timestamp }}

              ### [NOTE] Changements depuis la version précédente:
              ${{ steps.generate-notes.outputs.commits }}

              ### [LIEN] Liens utiles:
              - API: https://api.equipe.com
              - Health: https://api.equipe.com/health
              - Monitoring: https://grafana.equipe.com
            draft: false
            prerelease: false

    notify-production:
      name: "[ANNONCE] Notification Production"
      runs-on: ubuntu-22.04
      needs: [get-production-ip, deploy-production]
      if: always()
      steps:
        - name: [ANNONCE] Slack Notification Production
          uses: 8398a7/action-slack@v3
          with:
            status: ${{ needs.deploy-production.result }}
            text: |
              ${{ needs.deploy-production.result == 'success' && '[BRAVO]' || '[ALERTE]' }}
              *PRODUCTION* — ${{ env.APP_VERSION }}
              ${{ needs.deploy-production.result == 'success' && 'Déploiement réussi!' || 'DÉPLOIEMENT ÉCHOUÉ — Rollback effectué' }}
              URL: https://api.equipe.com
          env:
            SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 10.5 — WORKFLOW: TERRAFORM DESTROY (NETTOYAGE SÉCURISÉ)
────────────────────────────────────────────────────────────────────────────────

  POURQUOI CE WORKFLOW?
  ──────────────────────
  Détruire de l'infrastructure par accident est catastrophique.
  Ce workflow:
  -> Ne peut être déclenché QUE manuellement (workflow_dispatch)
  -> Requiert de taper le nom de l'environnement pour confirmation
  -> Envoie une notification Slack avant et après

  nano .github/workflows/terraform-destroy.yml

  Contenu:

  name: "[DANGER] Terraform Destroy (DANGER)"

  on:
    workflow_dispatch:
      inputs:
        environment:
          description: "Environnement à détruire"
          required: true
          type: choice
          options:
            - staging
            - production
        confirm_destroy:
          description: "Tapez exactement 'DESTROY' pour confirmer"
          required: true
        reason:
          description: "Raison de la destruction (pour les logs)"
          required: true

  jobs:
    terraform-destroy:
      name: "[DANGER] Destroy ${{ github.event.inputs.environment }}"
      runs-on: ubuntu-22.04
      environment:
        name: ${{ github.event.inputs.environment }}
        # La destruction de PRODUCTION nécessite l'approbation d'Alice

      steps:
        - name: [STOP] Vérifier la confirmation
          run: |
            if [ "${{ github.event.inputs.confirm_destroy }}" != "DESTROY" ]; then
              echo "[X] Confirmation invalide. Tapez exactement 'DESTROY'"
              exit 1
            fi
            echo "[OK] Confirmation acceptée. Destruction dans 60 secondes..."
            echo "Raison: ${{ github.event.inputs.reason }}"
            echo "Déclenchée par: ${{ github.actor }}"
            sleep 60   # Délai de grâce de 60 secondes

        - uses: actions/checkout@v4
        - uses: hashicorp/setup-terraform@v3
          with:
            terraform_version: "1.7.0"
            terraform_wrapper: false

        - name: [DANGER] Terraform Destroy
          run: |
            cd infrastructure/terraform/environments/${{ github.event.inputs.environment }}
            terraform init
            terraform destroy -auto-approve
          env:
            TF_VAR_do_token: ${{ secrets.DO_API_TOKEN }}
            TF_TOKEN_app_terraform_io: ${{ secrets.TF_API_TOKEN }}

        - name: [ANNONCE] Notification Destruction
          uses: 8398a7/action-slack@v3
          if: always()
          with:
            status: ${{ job.status }}
            text: |
              [DANGER] *DESTRUCTION* ${{ github.event.inputs.environment }}
              Par: ${{ github.actor }}
              Raison: ${{ github.event.inputs.reason }}
              Statut: ${{ job.status }}
          env:
            SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}


================================================================================
PARTIE 11 — CONTENU COMPLET DE L'APPLICATION FLASK TASKMANAGER
================================================================================

  POURQUOI CETTE PARTIE?
  ──────────────────────
  Les fichiers Terraform et Ansible référencent des fichiers de l'application
  Flask (run.py, app/, requirements.txt...). Voici le contenu COMPLET de chaque
  fichier pour que le déploiement fonctionne de A à Z.

  STRUCTURE COMPLÈTE DE L'APPLICATION:
  ──────────────────────────────────────
  taskmanager/
  ├── run.py                     <- Point d'entrée Flask
  ├── config.py                  <- Configuration par environnement
  ├── requirements.txt           <- Dépendances production
  ├── requirements-dev.txt       <- Dépendances développement
  ├── Dockerfile                 <- Image Docker
  ├── docker-compose.yml         <- Environnement de dev local
  ├── .env.example               <- Template de variables
  ├── .gitignore                 <- Fichiers exclus de Git
  ├── app/
  │   ├── __init__.py            <- Factory de l'application Flask
  │   ├── models.py              <- Modèles SQLAlchemy (Task, User)
  │   ├── routes/
  │   │   ├── __init__.py
  │   │   ├── tasks.py           <- Routes CRUD des tâches
  │   │   ├── users.py           <- Routes utilisateurs
  │   │   └── health.py          <- Routes health check + metrics
  │   ├── services/
  │   │   ├── __init__.py
  │   │   ├── task_service.py    <- Logique métier des tâches
  │   │   └── cache_service.py   <- Gestion du cache Redis
  │   ├── utils/
  │   │   ├── __init__.py
  │   │   ├── validators.py      <- Validation des données
  │   │   └── errors.py          <- Gestion des erreurs
  │   └── extensions.py          <- Extensions Flask (db, redis, migrate)
  └── tests/
      ├── conftest.py            <- Fixtures pytest
      ├── test_tasks.py          <- Tests des routes tâches
      ├── test_users.py          <- Tests des routes utilisateurs
      └── test_health.py         <- Tests du health check

────────────────────────────────────────────────────────────────────────────────
FICHIER: requirements.txt
────────────────────────────────────────────────────────────────────────────────

  # Dépendances de production TaskManager API
  # Généré avec: pip freeze > requirements.txt
  # Mettre à jour avec: pip-compile requirements.in

  Flask==3.0.0
  Flask-SQLAlchemy==3.1.1
  Flask-Migrate==4.0.5
  Flask-CORS==4.0.0

  # Base de données
  psycopg2-binary==2.9.9
  SQLAlchemy==2.0.23

  # Cache
  redis==5.0.1
  Flask-Caching==2.1.0

  # Serveur WSGI
  gunicorn==21.2.0

  # Validation et sérialisation
  marshmallow==3.20.1
  Flask-Marshmallow==1.1.0
  marshmallow-sqlalchemy==1.0.0

  # Sécurité
  Flask-Talisman==1.1.0
  python-dotenv==1.0.0
  cryptography==42.0.0

  # Monitoring Prometheus
  prometheus-flask-exporter==0.23.0

  # Utilitaires
  python-dateutil==2.8.2
  pytz==2024.1

────────────────────────────────────────────────────────────────────────────────
FICHIER: requirements-dev.txt
────────────────────────────────────────────────────────────────────────────────

  # Dépendances de développement et tests
  -r requirements.txt

  # Tests
  pytest==7.4.3
  pytest-flask==1.3.0
  pytest-cov==4.1.0
  pytest-mock==3.12.0
  factory-boy==3.3.0

  # Qualité de code
  flake8==7.0.0
  black==24.2.0
  isort==5.13.2
  mypy==1.7.1

  # Dev tools
  ipdb==0.13.13

────────────────────────────────────────────────────────────────────────────────
FICHIER: run.py
────────────────────────────────────────────────────────────────────────────────

  #!/usr/bin/env python3
  """
  Point d'entrée de l'application TaskManager API.
  Utilisé par: Gunicorn, Flask CLI, tests.
  """
  import os
  from app import create_app

  # Créer l'application selon l'environnement
  app = create_app(os.environ.get('FLASK_ENV', 'production'))

  if __name__ == '__main__':
      port = int(os.environ.get('PORT', 5000))
      debug = os.environ.get('FLASK_DEBUG', '0') == '1'
      app.run(
          host='0.0.0.0',
          port=port,
          debug=debug
      )

────────────────────────────────────────────────────────────────────────────────
FICHIER: config.py
────────────────────────────────────────────────────────────────────────────────

  """
  Configuration de l'application Flask par environnement.
  Chaque environnement hérite de Config de base et surcharge ce dont il a besoin.
  """
  import os
  from datetime import timedelta


  class Config:
      """Configuration de base — commune à tous les environnements."""
      # Flask
      SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-insecure-key-change-in-prod'
      APP_NAME = 'TaskManager API'
      APP_VERSION = os.environ.get('APP_VERSION', '1.0.0')

      # Base de données
      SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
          'postgresql://taskmanager:password@localhost:5432/taskmanager'
      SQLALCHEMY_TRACK_MODIFICATIONS = False
      SQLALCHEMY_ECHO = False
      SQLALCHEMY_ENGINE_OPTIONS = {
          'pool_size': 10,
          'pool_recycle': 3600,
          'pool_pre_ping': True,   # Vérifier la connexion avant utilisation
      }

      # Redis
      REDIS_URL = os.environ.get('REDIS_URL') or 'redis://localhost:6379/0'
      CACHE_TYPE = 'redis'
      CACHE_REDIS_URL = REDIS_URL
      CACHE_DEFAULT_TIMEOUT = 300   # 5 minutes

      # CORS
      CORS_ORIGINS = os.environ.get('CORS_ORIGINS', '*').split(',')

      # Pagination
      TASKS_PER_PAGE = 20
      MAX_TASKS_PER_PAGE = 100

      # Logging
      LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO')
      LOG_FILE = os.environ.get('LOG_FILE', '/var/log/taskmanager/app.log')

      # Prometheus Metrics
      PROMETHEUS_ENABLED = os.environ.get('PROMETHEUS_ENABLED', 'false').lower() == 'true'


  class DevelopmentConfig(Config):
      """Configuration pour le développement local."""
      DEBUG = True
      SQLALCHEMY_ECHO = True   # Afficher les requêtes SQL
      CACHE_TYPE = 'SimpleCache'   # Cache en mémoire (pas besoin de Redis en dev)
      CORS_ORIGINS = ['*']


  class TestingConfig(Config):
      """Configuration pour les tests automatisés."""
      TESTING = True
      DEBUG = True
      SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
          'postgresql://taskmanager:test_password_ci@localhost:5432/taskmanager_test'
      CACHE_TYPE = 'SimpleCache'
      WTF_CSRF_ENABLED = False
      TASKS_PER_PAGE = 5   # Petite valeur pour tester la pagination


  class StagingConfig(Config):
      """Configuration pour l'environnement de staging."""
      DEBUG = os.environ.get('FLASK_DEBUG', '0') == '1'
      LOG_LEVEL = 'DEBUG'


  class ProductionConfig(Config):
      """Configuration pour la production."""
      DEBUG = False
      LOG_LEVEL = 'WARNING'
      SQLALCHEMY_ENGINE_OPTIONS = {
          **Config.SQLALCHEMY_ENGINE_OPTIONS,
          'pool_size': 20,      # Plus de connexions en production
          'max_overflow': 30,
      }


  # Mapping nom -> classe de configuration
  config_map = {
      'development': DevelopmentConfig,
      'testing': TestingConfig,
      'staging': StagingConfig,
      'production': ProductionConfig,
  }

────────────────────────────────────────────────────────────────────────────────
FICHIER: app/__init__.py
────────────────────────────────────────────────────────────────────────────────

  """
  Factory Flask: crée et configure l'application.
  Pattern "Application Factory" = créer l'app avec une fonction.
  Avantages:
  - Créer plusieurs instances avec des configs différentes (tests vs prod)
  - Éviter les imports circulaires
  - Faciliter les tests unitaires
  """
  import logging
  import os
  from logging.handlers import RotatingFileHandler

  from flask import Flask, jsonify
  from flask_cors import CORS

  from config import config_map
  from app.extensions import db, migrate, cache, redis_client


  def create_app(config_name='production'):
      """
      Créer et configurer l'application Flask.
      
      Args:
          config_name: Nom de l'environnement ('development', 'testing', 'staging', 'production')
      
      Returns:
          app: Instance Flask configurée
      """
      app = Flask(__name__)

      # Charger la configuration selon l'environnement
      config_class = config_map.get(config_name, config_map['production'])
      app.config.from_object(config_class)

      # Initialiser les extensions
      db.init_app(app)
      migrate.init_app(app, db)
      cache.init_app(app)
      redis_client.init_app(app)
      CORS(app, origins=app.config['CORS_ORIGINS'])

      # Enregistrer les blueprints (routes)
      from app.routes.tasks import tasks_bp
      from app.routes.users import users_bp
      from app.routes.health import health_bp

      app.register_blueprint(tasks_bp, url_prefix='/tasks')
      app.register_blueprint(users_bp, url_prefix='/users')
      app.register_blueprint(health_bp)

      # Configurer le logging
      _configure_logging(app)

      # Configurer Prometheus metrics
      if app.config.get('PROMETHEUS_ENABLED'):
          from prometheus_flask_exporter import PrometheusMetrics
          metrics = PrometheusMetrics(app)
          metrics.info('app_info', 'Application info',
                       version=app.config.get('APP_VERSION', 'unknown'))

      # Gestionnaire d'erreurs globaux
      _register_error_handlers(app)

      return app


  def _configure_logging(app):
      """Configurer le système de logs."""
      log_level = getattr(logging, app.config.get('LOG_LEVEL', 'INFO'))

      # Formatter avec timestamp, niveau et message
      formatter = logging.Formatter(
          '%(asctime)s [%(levelname)s] %(name)s: %(message)s',
          datefmt='%Y-%m-%d %H:%M:%S'
      )

      # Handler console (toujours actif)
      console_handler = logging.StreamHandler()
      console_handler.setFormatter(formatter)
      console_handler.setLevel(log_level)

      app.logger.setLevel(log_level)
      app.logger.addHandler(console_handler)

      # Handler fichier (seulement si le dossier existe)
      log_file = app.config.get('LOG_FILE')
      if log_file:
          log_dir = os.path.dirname(log_file)
          if os.path.exists(log_dir):
              file_handler = RotatingFileHandler(
                  log_file,
                  maxBytes=10 * 1024 * 1024,   # 10 MB par fichier
                  backupCount=5                  # Garder 5 fichiers de backup
              )
              file_handler.setFormatter(formatter)
              file_handler.setLevel(log_level)
              app.logger.addHandler(file_handler)


  def _register_error_handlers(app):
      """Enregistrer les gestionnaires d'erreurs HTTP."""

      @app.errorhandler(400)
      def bad_request(e):
          return jsonify({'error': 'Requête invalide', 'message': str(e)}), 400

      @app.errorhandler(404)
      def not_found(e):
          return jsonify({'error': 'Ressource introuvable', 'message': str(e)}), 404

      @app.errorhandler(405)
      def method_not_allowed(e):
          return jsonify({'error': 'Méthode non autorisée', 'message': str(e)}), 405

      @app.errorhandler(422)
      def unprocessable_entity(e):
          return jsonify({'error': 'Données invalides', 'message': str(e)}), 422

      @app.errorhandler(500)
      def internal_error(e):
          db.session.rollback()   # Rollback la transaction en cours si erreur DB
          return jsonify({'error': 'Erreur interne du serveur'}), 500

────────────────────────────────────────────────────────────────────────────────
FICHIER: app/extensions.py
────────────────────────────────────────────────────────────────────────────────

  """
  Extensions Flask: instances créées ici, initialisées dans create_app().
  Pattern: créer les extensions sans app, puis init_app(app) plus tard.
  Évite les imports circulaires.
  """
  from flask_sqlalchemy import SQLAlchemy
  from flask_migrate import Migrate
  from flask_caching import Cache
  from flask_redis import FlaskRedis

  db = SQLAlchemy()
  migrate = Migrate()
  cache = Cache()
  redis_client = FlaskRedis()

────────────────────────────────────────────────────────────────────────────────
FICHIER: app/models.py
────────────────────────────────────────────────────────────────────────────────

  """
  Modèles SQLAlchemy (représentation des tables PostgreSQL en Python).
  Chaque classe = une table dans la base de données.
  """
  from datetime import datetime, timezone
  from app.extensions import db


  class Task(db.Model):
      """
      Modèle Task: représente une tâche dans la base de données.
      Table: tasks
      """
      __tablename__ = 'tasks'

      # Colonnes
      id          = db.Column(db.Integer, primary_key=True)
      title       = db.Column(db.String(200), nullable=False, index=True)
      description = db.Column(db.Text, nullable=True)
      done        = db.Column(db.Boolean, nullable=False, default=False)
      priority    = db.Column(
          db.Enum('low', 'medium', 'high', 'critical', name='priority_enum'),
          nullable=False,
          default='medium'
      )
      category    = db.Column(db.String(50), nullable=True, index=True)
      due_date    = db.Column(db.DateTime(timezone=True), nullable=True)
      created_at  = db.Column(
          db.DateTime(timezone=True),
          nullable=False,
          default=lambda: datetime.now(timezone.utc)
      )
      updated_at  = db.Column(
          db.DateTime(timezone=True),
          nullable=False,
          default=lambda: datetime.now(timezone.utc),
          onupdate=lambda: datetime.now(timezone.utc)
      )
      user_id     = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)

      # Index composites pour les requêtes fréquentes
      __table_args__ = (
          db.Index('idx_tasks_done_priority', 'done', 'priority'),
          db.Index('idx_tasks_created_at', 'created_at'),
          db.Index('idx_tasks_due_date', 'due_date'),
      )

      def to_dict(self):
          """Convertir en dictionnaire JSON-sérialisable."""
          return {
              'id':          self.id,
              'title':       self.title,
              'description': self.description,
              'done':        self.done,
              'priority':    self.priority,
              'category':    self.category,
              'due_date':    self.due_date.isoformat() if self.due_date else None,
              'created_at':  self.created_at.isoformat(),
              'updated_at':  self.updated_at.isoformat(),
              'user_id':     self.user_id,
          }

      def __repr__(self):
          return f'<Task {self.id}: {self.title[:30]}>'


  class User(db.Model):
      """Modèle User: utilisateur de l'API."""
      __tablename__ = 'users'

      id         = db.Column(db.Integer, primary_key=True)
      username   = db.Column(db.String(80), unique=True, nullable=False, index=True)
      email      = db.Column(db.String(120), unique=True, nullable=False, index=True)
      created_at = db.Column(
          db.DateTime(timezone=True),
          nullable=False,
          default=lambda: datetime.now(timezone.utc)
      )
      is_active  = db.Column(db.Boolean, nullable=False, default=True)

      # Relation: un utilisateur peut avoir plusieurs tâches
      tasks = db.relationship('Task', backref='user', lazy='dynamic',
                               cascade='all, delete-orphan')

      def to_dict(self):
          return {
              'id':         self.id,
              'username':   self.username,
              'email':      self.email,
              'created_at': self.created_at.isoformat(),
              'is_active':  self.is_active,
              'task_count': self.tasks.count(),
          }

      def __repr__(self):
          return f'<User {self.username}>'

────────────────────────────────────────────────────────────────────────────────
FICHIER: app/routes/health.py
────────────────────────────────────────────────────────────────────────────────

  """
  Routes de health check et métriques.
  Ces routes sont utilisées par:
  - Ansible: pour vérifier que l'app est opérationnelle après déploiement
  - Nginx: pour le health check du load balancer
  - Kubernetes: readiness/liveness probes (si migration future)
  - Prometheus: collecte des métriques via /metrics
  """
  import time
  from datetime import datetime, timezone
  import os

  from flask import Blueprint, jsonify, current_app
  from sqlalchemy import text

  from app.extensions import db, redis_client

  health_bp = Blueprint('health', __name__)


  @health_bp.route('/health')
  def health_check():
      """
      Health check complet: vérifie DB, Redis, et retourne les métriques de base.
      Retourne 200 si tout va bien, 503 si un service est en panne.
      """
      checks = {}
      overall_status = 'healthy'
      http_code = 200

      # Vérifier PostgreSQL
      try:
          db.session.execute(text('SELECT 1'))
          db.session.commit()
          checks['database'] = 'ok'
      except Exception as e:
          checks['database'] = f'error: {str(e)}'
          overall_status = 'degraded'
          http_code = 503
          current_app.logger.error(f'Health check DB failed: {e}')

      # Vérifier Redis
      try:
          redis_client.ping()
          checks['redis'] = 'ok'
      except Exception as e:
          checks['redis'] = f'error: {str(e)}'
          overall_status = 'degraded'
          # Redis en panne = dégradé mais pas critique (on peut fonctionner sans cache)
          current_app.logger.warning(f'Health check Redis failed: {e}')

      return jsonify({
          'status':      overall_status,
          'timestamp':   datetime.now(timezone.utc).isoformat(),
          'version':     current_app.config.get('APP_VERSION', 'unknown'),
          'environment': current_app.config.get('ENV', 'unknown'),
          'checks':      checks,
          'uptime_pid':  os.getpid(),
      }), http_code


  @health_bp.route('/health/ready')
  def readiness():
      """
      Readiness probe: l'application est-elle PRÊTE à recevoir des requêtes?
      Vérifie les dépendances critiques (DB).
      Utilisé par les load balancers pour savoir quand envoyer du trafic.
      """
      try:
          db.session.execute(text('SELECT 1'))
          return jsonify({'ready': True}), 200
      except Exception:
          return jsonify({'ready': False, 'reason': 'database_unavailable'}), 503


  @health_bp.route('/health/live')
  def liveness():
      """
      Liveness probe: l'application est-elle VIVANTE?
      Si non, le gestionnaire de processus (systemd/K8s) la redémarre.
      Vérification minimale: l'application répond.
      """
      return jsonify({'alive': True, 'pid': os.getpid()}), 200


  @health_bp.route('/version')
  def version():
      """Retourner la version de l'application (utilisé par les scripts de déploiement)."""
      return jsonify({
          'version':     current_app.config.get('APP_VERSION', 'unknown'),
          'environment': os.environ.get('FLASK_ENV', 'unknown'),
          'python':      __import__('sys').version,
      }), 200

────────────────────────────────────────────────────────────────────────────────
FICHIER: app/routes/tasks.py
────────────────────────────────────────────────────────────────────────────────

  """
  Routes CRUD pour les tâches.
  GET    /tasks           -> Liste des tâches (paginée, filtrable, triable)
  POST   /tasks           -> Créer une tâche
  GET    /tasks/<id>      -> Voir une tâche
  PUT    /tasks/<id>      -> Modifier une tâche
  PATCH  /tasks/<id>/done -> Marquer comme terminée
  DELETE /tasks/<id>      -> Supprimer une tâche
  GET    /tasks/stats     -> Statistiques
  """
  from datetime import datetime

  from flask import Blueprint, jsonify, request, current_app, abort
  from sqlalchemy import or_, desc, asc

  from app.extensions import db, cache
  from app.models import Task
  from app.utils.validators import validate_task_data, validate_date

  tasks_bp = Blueprint('tasks', __name__)


  @tasks_bp.route('', methods=['GET'])
  @cache.cached(timeout=60, query_string=True)   # Cache 60s, clé inclut les query params
  def get_tasks():
      """
      Lister les tâches avec filtres, tri et pagination.
      
      Query params:
        - page:     Numéro de page (défaut: 1)
        - per_page: Tâches par page (défaut: 20, max: 100)
        - done:     Filtrer par statut (true/false)
        - priority: Filtrer par priorité (low/medium/high/critical)
        - category: Filtrer par catégorie
        - search:   Recherche dans le titre et la description
        - sort:     Trier par (created_at/due_date/priority/title) (défaut: created_at)
        - order:    Ordre de tri (asc/desc) (défaut: desc)
        - from:     Date de début (format: YYYY-MM-DD)
        - to:       Date de fin (format: YYYY-MM-DD)
      """
      # Paramètres de pagination
      try:
          page = int(request.args.get('page', 1))
          per_page = min(
              int(request.args.get('per_page', current_app.config['TASKS_PER_PAGE'])),
              current_app.config['MAX_TASKS_PER_PAGE']
          )
      except (ValueError, TypeError):
          return jsonify({'error': 'Paramètres de pagination invalides'}), 400

      # Construire la requête de base
      query = Task.query

      # Filtre par statut done
      done_param = request.args.get('done')
      if done_param is not None:
          if done_param.lower() not in ('true', 'false'):
              return jsonify({'error': 'Paramètre done doit être true ou false'}), 400
          query = query.filter(Task.done == (done_param.lower() == 'true'))

      # Filtre par priorité
      priority = request.args.get('priority')
      if priority:
          valid_priorities = ['low', 'medium', 'high', 'critical']
          if priority not in valid_priorities:
              return jsonify({
                  'error': f"Priorité invalide. Valeurs: {', '.join(valid_priorities)}"
              }), 400
          query = query.filter(Task.priority == priority)

      # Filtre par catégorie
      category = request.args.get('category')
      if category:
          query = query.filter(Task.category == category)

      # Recherche textuelle
      search = request.args.get('search')
      if search:
          search_term = f'%{search}%'
          query = query.filter(
              or_(
                  Task.title.ilike(search_term),
                  Task.description.ilike(search_term)
              )
          )

      # Filtre par plage de dates (feature de Bob)
      from_date = request.args.get('from')
      to_date = request.args.get('to')
      if from_date:
          parsed_from = validate_date(from_date, 'from')
          if parsed_from is None:
              return jsonify({'error': "Format 'from' invalide. Utiliser YYYY-MM-DD"}), 400
          query = query.filter(Task.created_at >= parsed_from)
      if to_date:
          parsed_to = validate_date(to_date, 'to')
          if parsed_to is None:
              return jsonify({'error': "Format 'to' invalide. Utiliser YYYY-MM-DD"}), 400
          query = query.filter(Task.created_at <= parsed_to)

      # Tri
      sort_field = request.args.get('sort', 'created_at')
      sort_order = request.args.get('order', 'desc').lower()
      valid_sort_fields = {'created_at': Task.created_at, 'due_date': Task.due_date,
                           'priority': Task.priority, 'title': Task.title}
      if sort_field not in valid_sort_fields:
          return jsonify({'error': f"Tri invalide. Champs: {', '.join(valid_sort_fields)}"}), 400

      sort_column = valid_sort_fields[sort_field]
      if sort_order == 'asc':
          query = query.order_by(asc(sort_column))
      else:
          query = query.order_by(desc(sort_column))

      # Exécuter la requête paginée
      paginated = query.paginate(page=page, per_page=per_page, error_out=False)

      return jsonify({
          'tasks':    [task.to_dict() for task in paginated.items],
          'total':    paginated.total,
          'page':     paginated.page,
          'pages':    paginated.pages,
          'per_page': paginated.per_page,
          'has_next': paginated.has_next,
          'has_prev': paginated.has_prev,
      }), 200


  @tasks_bp.route('', methods=['POST'])
  def create_task():
      """Créer une nouvelle tâche."""
      data = request.get_json()
      if not data:
          return jsonify({'error': 'Corps JSON requis'}), 400

      errors = validate_task_data(data, is_creation=True)
      if errors:
          return jsonify({'error': 'Données invalides', 'details': errors}), 422

      task = Task(
          title=data['title'].strip(),
          description=data.get('description', '').strip() or None,
          done=data.get('done', False),
          priority=data.get('priority', 'medium'),
          category=data.get('category'),
          due_date=datetime.fromisoformat(data['due_date']) if data.get('due_date') else None,
          user_id=data.get('user_id'),
      )

      try:
          db.session.add(task)
          db.session.commit()
          cache.clear()   # Invalider le cache après création
          current_app.logger.info(f'Tâche créée: id={task.id}, title={task.title}')
          return jsonify(task.to_dict()), 201
      except Exception as e:
          db.session.rollback()
          current_app.logger.error(f'Erreur création tâche: {e}')
          return jsonify({'error': 'Erreur lors de la création'}), 500


  @tasks_bp.route('/<int:task_id>', methods=['GET'])
  @cache.cached(timeout=120)
  def get_task(task_id):
      """Récupérer une tâche par son ID."""
      task = db.session.get(Task, task_id)
      if not task:
          abort(404, description=f'Tâche {task_id} introuvable')
      return jsonify(task.to_dict()), 200


  @tasks_bp.route('/<int:task_id>', methods=['PUT'])
  def update_task(task_id):
      """Modifier complètement une tâche (remplace tous les champs fournis)."""
      task = db.session.get(Task, task_id)
      if not task:
          abort(404, description=f'Tâche {task_id} introuvable')

      data = request.get_json()
      if not data:
          return jsonify({'error': 'Corps JSON requis'}), 400

      errors = validate_task_data(data, is_creation=False)
      if errors:
          return jsonify({'error': 'Données invalides', 'details': errors}), 422

      # Mettre à jour seulement les champs fournis
      if 'title' in data:
          task.title = data['title'].strip()
      if 'description' in data:
          task.description = data['description'].strip() or None
      if 'done' in data:
          task.done = bool(data['done'])
      if 'priority' in data:
          task.priority = data['priority']
      if 'category' in data:
          task.category = data['category']
      if 'due_date' in data:
          task.due_date = datetime.fromisoformat(data['due_date']) if data['due_date'] else None

      try:
          db.session.commit()
          cache.delete_memoized(get_task, task_id)
          cache.clear()
          return jsonify(task.to_dict()), 200
      except Exception as e:
          db.session.rollback()
          current_app.logger.error(f'Erreur mise à jour tâche {task_id}: {e}')
          return jsonify({'error': 'Erreur lors de la mise à jour'}), 500


  @tasks_bp.route('/<int:task_id>/done', methods=['PATCH'])
  def toggle_task_done(task_id):
      """Basculer le statut done d'une tâche (raccourci pratique)."""
      task = db.session.get(Task, task_id)
      if not task:
          abort(404, description=f'Tâche {task_id} introuvable')

      task.done = not task.done
      db.session.commit()
      cache.clear()

      return jsonify({'id': task.id, 'done': task.done}), 200


  @tasks_bp.route('/<int:task_id>', methods=['DELETE'])
  def delete_task(task_id):
      """Supprimer une tâche."""
      task = db.session.get(Task, task_id)
      if not task:
          abort(404, description=f'Tâche {task_id} introuvable')

      try:
          db.session.delete(task)
          db.session.commit()
          cache.clear()
          current_app.logger.info(f'Tâche supprimée: id={task_id}')
          return '', 204
      except Exception as e:
          db.session.rollback()
          current_app.logger.error(f'Erreur suppression tâche {task_id}: {e}')
          return jsonify({'error': 'Erreur lors de la suppression'}), 500


  @tasks_bp.route('/stats', methods=['GET'])
  @cache.cached(timeout=300)   # Cache 5 minutes (moins volatile que la liste)
  def get_stats():
      """Statistiques agrégées des tâches."""
      from sqlalchemy import func

      total          = Task.query.count()
      done           = Task.query.filter_by(done=True).count()
      pending        = total - done
      by_priority    = dict(db.session.query(Task.priority, func.count(Task.id))
                              .group_by(Task.priority).all())
      by_category    = dict(db.session.query(Task.category, func.count(Task.id))
                              .filter(Task.category.isnot(None))
                              .group_by(Task.category).all())

      return jsonify({
          'total':       total,
          'done':        done,
          'pending':     pending,
          'completion':  round(done / total * 100, 1) if total > 0 else 0,
          'by_priority': by_priority,
          'by_category': by_category,
      }), 200

────────────────────────────────────────────────────────────────────────────────
FICHIER: app/utils/validators.py
────────────────────────────────────────────────────────────────────────────────

  """
  Validateurs pour les données entrantes de l'API.
  """
  from datetime import datetime


  VALID_PRIORITIES = {'low', 'medium', 'high', 'critical'}


  def validate_task_data(data: dict, is_creation: bool = True) -> dict:
      """
      Valider les données d'une tâche.
      
      Returns:
          Dictionnaire d'erreurs (vide si tout est valide).
      """
      errors = {}

      # Validation du titre (obligatoire à la création)
      if is_creation and 'title' not in data:
          errors['title'] = 'Le titre est obligatoire'
      elif 'title' in data:
          title = data['title']
          if not isinstance(title, str) or not title.strip():
              errors['title'] = 'Le titre doit être une chaîne non vide'
          elif len(title.strip()) > 200:
              errors['title'] = 'Le titre ne peut pas dépasser 200 caractères'

      # Validation de la priorité
      if 'priority' in data:
          if data['priority'] not in VALID_PRIORITIES:
              errors['priority'] = f"Priorité invalide. Valeurs: {', '.join(sorted(VALID_PRIORITIES))}"

      # Validation de done
      if 'done' in data and not isinstance(data['done'], bool):
          errors['done'] = 'done doit être un booléen (true/false)'

      # Validation de due_date
      if 'due_date' in data and data['due_date'] is not None:
          if validate_date(data['due_date'], 'due_date') is None:
              errors['due_date'] = 'Format de date invalide. Utiliser ISO 8601 (YYYY-MM-DD ou YYYY-MM-DDTHH:MM:SS)'

      return errors


  def validate_date(date_str: str, field_name: str):
      """
      Valider et parser une date.
      Formats acceptés: YYYY-MM-DD, YYYY-MM-DDTHH:MM:SS, YYYY-MM-DDTHH:MM:SSZ
      
      Returns:
          datetime object si valide, None si invalide.
      """
      if not date_str:
          return None
      
      formats = [
          '%Y-%m-%d',
          '%Y-%m-%dT%H:%M:%S',
          '%Y-%m-%dT%H:%M:%SZ',
          '%Y-%m-%dT%H:%M:%S.%f',
          '%Y-%m-%dT%H:%M:%S.%fZ',
      ]
      
      for fmt in formats:
          try:
              return datetime.strptime(date_str, fmt)
          except ValueError:
              continue
      
      return None   # Aucun format ne correspond

────────────────────────────────────────────────────────────────────────────────
FICHIER: tests/conftest.py
────────────────────────────────────────────────────────────────────────────────

  """
  Fixtures pytest: configuration partagée entre tous les tests.
  Les fixtures évitent de répéter setup/teardown dans chaque test.
  """
  import pytest
  from app import create_app
  from app.extensions import db as _db
  from app.models import Task, User


  @pytest.fixture(scope='session')
  def app():
      """Créer l'application Flask en mode test (session entière)."""
      app = create_app('testing')
      app.config['TESTING'] = True
      with app.app_context():
          _db.create_all()   # Créer toutes les tables
          yield app
          _db.drop_all()     # Supprimer toutes les tables après les tests


  @pytest.fixture(scope='function')
  def client(app):
      """Client HTTP de test. Recrée une transaction propre pour chaque test."""
      with app.test_client() as client:
          with app.app_context():
              yield client


  @pytest.fixture(scope='function', autouse=True)
  def clean_db(app):
      """Nettoyer la DB avant chaque test (transactions isolées)."""
      with app.app_context():
          # Supprimer toutes les données dans l'ordre des dépendances FK
          Task.query.delete()
          User.query.delete()
          _db.session.commit()
          yield
          _db.session.rollback()


  @pytest.fixture
  def sample_task(app):
      """Créer une tâche d'exemple pour les tests."""
      with app.app_context():
          task = Task(title='Tâche de test', priority='medium', done=False)
          _db.session.add(task)
          _db.session.commit()
          return task.to_dict()   # Retourner un dict (évite les sessions détachées)


  @pytest.fixture
  def multiple_tasks(app):
      """Créer plusieurs tâches pour tester la pagination et les filtres."""
      with app.app_context():
          tasks = [
              Task(title='Tâche haute priorité', priority='high', done=False, category='work'),
              Task(title='Tâche terminée', priority='low', done=True, category='personal'),
              Task(title='Tâche critique', priority='critical', done=False, category='work'),
              Task(title='Tâche normale', priority='medium', done=False, category='personal'),
              Task(title='Autre tâche', priority='medium', done=True, category=None),
          ]
          for task in tasks:
              _db.session.add(task)
          _db.session.commit()
          return [t.to_dict() for t in tasks]

────────────────────────────────────────────────────────────────────────────────
FICHIER: tests/test_tasks.py
────────────────────────────────────────────────────────────────────────────────

  """
  Tests des routes CRUD des tâches.
  Structure: Arrange (préparer) -> Act (exécuter) -> Assert (vérifier)
  """
  import json
  import pytest


  class TestCreateTask:
      """Tests de la création de tâche (POST /tasks)."""

      def test_create_task_success(self, client):
          """Créer une tâche valide doit retourner 201."""
          response = client.post('/tasks',
              json={'title': 'Ma nouvelle tâche', 'priority': 'high'})

          assert response.status_code == 201
          data = response.get_json()
          assert data['title'] == 'Ma nouvelle tâche'
          assert data['priority'] == 'high'
          assert data['done'] is False
          assert 'id' in data
          assert 'created_at' in data

      def test_create_task_without_title_returns_422(self, client):
          """Créer une tâche sans titre doit retourner 422."""
          response = client.post('/tasks', json={'priority': 'high'})
          assert response.status_code == 422
          data = response.get_json()
          assert 'title' in data.get('details', {})

      def test_create_task_with_invalid_priority_returns_422(self, client):
          """Priorité invalide doit retourner 422."""
          response = client.post('/tasks',
              json={'title': 'Test', 'priority': 'super-urgent'})
          assert response.status_code == 422

      def test_create_task_without_json_returns_400(self, client):
          """Requête sans JSON doit retourner 400."""
          response = client.post('/tasks',
              data='not json', content_type='text/plain')
          assert response.status_code == 400

      def test_create_task_with_all_fields(self, client):
          """Créer une tâche avec tous les champs optionnels."""
          response = client.post('/tasks', json={
              'title':       'Tâche complète',
              'description': 'Description détaillée',
              'priority':    'critical',
              'category':    'work',
              'due_date':    '2025-12-31',
              'done':        False,
          })
          assert response.status_code == 201
          data = response.get_json()
          assert data['description'] == 'Description détaillée'
          assert data['category'] == 'work'


  class TestGetTasks:
      """Tests de la liste des tâches (GET /tasks)."""

      def test_get_tasks_empty_list(self, client):
          """Liste vide si aucune tâche."""
          response = client.get('/tasks')
          assert response.status_code == 200
          data = response.get_json()
          assert data['total'] == 0
          assert data['tasks'] == []

      def test_get_tasks_pagination(self, client, multiple_tasks):
          """Tester la pagination."""
          response = client.get('/tasks?page=1&per_page=2')
          assert response.status_code == 200
          data = response.get_json()
          assert len(data['tasks']) == 2
          assert data['per_page'] == 2
          assert data['total'] == 5
          assert data['pages'] == 3
          assert data['has_next'] is True
          assert data['has_prev'] is False

      def test_filter_by_done(self, client, multiple_tasks):
          """Filtrer par statut done."""
          response = client.get('/tasks?done=true')
          data = response.get_json()
          assert all(t['done'] for t in data['tasks'])

          response = client.get('/tasks?done=false')
          data = response.get_json()
          assert all(not t['done'] for t in data['tasks'])

      def test_filter_by_priority(self, client, multiple_tasks):
          """Filtrer par priorité."""
          response = client.get('/tasks?priority=high')
          data = response.get_json()
          assert all(t['priority'] == 'high' for t in data['tasks'])

      def test_filter_by_date_range(self, client, multiple_tasks):
          """Filtrer par plage de dates (feature de Bob)."""
          response = client.get('/tasks?from=2020-01-01&to=2030-12-31')
          data = response.get_json()
          assert data['total'] == 5   # Toutes les tâches dans cette période

      def test_invalid_date_format_returns_400(self, client):
          """Date invalide doit retourner 400."""
          response = client.get('/tasks?from=31-12-2024')   # Format incorrect
          assert response.status_code == 400

      def test_search(self, client, multiple_tasks):
          """Recherche textuelle dans le titre."""
          response = client.get('/tasks?search=critique')
          data = response.get_json()
          assert data['total'] >= 1
          assert any('ritique' in t['title'] for t in data['tasks'])

      def test_sort_by_title_asc(self, client, multiple_tasks):
          """Tri par titre ascendant."""
          response = client.get('/tasks?sort=title&order=asc')
          data = response.get_json()
          titles = [t['title'] for t in data['tasks']]
          assert titles == sorted(titles)


  class TestUpdateTask:
      """Tests de la mise à jour de tâche (PUT /tasks/<id>)."""

      def test_update_task_title(self, client, sample_task):
          """Modifier le titre d'une tâche."""
          response = client.put(f'/tasks/{sample_task["id"]}',
              json={'title': 'Nouveau titre'})
          assert response.status_code == 200
          data = response.get_json()
          assert data['title'] == 'Nouveau titre'

      def test_update_nonexistent_task_returns_404(self, client):
          """Modifier une tâche inexistante doit retourner 404."""
          response = client.put('/tasks/99999', json={'title': 'Test'})
          assert response.status_code == 404


  class TestDeleteTask:
      """Tests de la suppression de tâche (DELETE /tasks/<id>)."""

      def test_delete_task(self, client, sample_task):
          """Supprimer une tâche existante doit retourner 204."""
          response = client.delete(f'/tasks/{sample_task["id"]}')
          assert response.status_code == 204

          # Vérifier que la tâche n'existe plus:
          get_response = client.get(f'/tasks/{sample_task["id"]}')
          assert get_response.status_code == 404

      def test_delete_nonexistent_task_returns_404(self, client):
          """Supprimer une tâche inexistante doit retourner 404."""
          response = client.delete('/tasks/99999')
          assert response.status_code == 404


  class TestTaskStats:
      """Tests des statistiques (GET /tasks/stats)."""

      def test_stats_empty_db(self, client):
          """Stats avec DB vide."""
          response = client.get('/tasks/stats')
          assert response.status_code == 200
          data = response.get_json()
          assert data['total'] == 0
          assert data['completion'] == 0

      def test_stats_with_tasks(self, client, multiple_tasks):
          """Stats avec tâches."""
          response = client.get('/tasks/stats')
          data = response.get_json()
          assert data['total'] == 5
          assert data['done'] == 2
          assert data['pending'] == 3
          assert data['completion'] == 40.0

────────────────────────────────────────────────────────────────────────────────
FICHIER: tests/test_health.py
────────────────────────────────────────────────────────────────────────────────

  """Tests du health check."""


  def test_health_check_returns_200(client):
      """Le health check doit retourner 200 quand tout va bien."""
      response = client.get('/health')
      assert response.status_code == 200
      data = response.get_json()
      assert data['status'] == 'healthy'
      assert 'timestamp' in data
      assert 'version' in data
      assert data['checks']['database'] == 'ok'


  def test_readiness_probe(client):
      """La readiness probe doit retourner 200 quand la DB est accessible."""
      response = client.get('/health/ready')
      assert response.status_code == 200
      assert response.get_json()['ready'] is True


  def test_liveness_probe(client):
      """La liveness probe doit toujours retourner 200."""
      response = client.get('/health/live')
      assert response.status_code == 200
      assert response.get_json()['alive'] is True


  def test_version_endpoint(client):
      """L'endpoint version doit retourner les infos de version."""
      response = client.get('/version')
      assert response.status_code == 200
      data = response.get_json()
      assert 'version' in data
      assert 'python' in data

────────────────────────────────────────────────────────────────────────────────
FICHIER: Dockerfile
────────────────────────────────────────────────────────────────────────────────

  # ═══════════════════════════════════════════════════════════════════════════
  # Dockerfile — Image de production TaskManager API
  # Build multi-stage: minimiser la taille de l'image finale
  # ═══════════════════════════════════════════════════════════════════════════

  # ── STAGE 1: Builder (compiler les dépendances) ──────────────────────────
  FROM python:3.11-slim AS builder

  # Installer les dépendances système pour compiler les extensions Python
  RUN apt-get update && apt-get install -y \
      gcc \
      g++ \
      libpq-dev \
      libffi-dev \
      libssl-dev \
      && rm -rf /var/lib/apt/lists/*

  WORKDIR /build

  # Copier et installer les dépendances dans un venv isolé
  COPY requirements.txt .
  RUN python -m venv /build/venv
  ENV PATH="/build/venv/bin:$PATH"
  RUN pip install --no-cache-dir --upgrade pip && \
      pip install --no-cache-dir -r requirements.txt


  # ── STAGE 2: Image finale (sans les outils de compilation) ─────────────
  FROM python:3.11-slim AS production

  # Metadata
  LABEL maintainer="equipe@equipe.com"
  LABEL description="TaskManager API Flask"

  # Installer seulement les dépendances RUNTIME (pas les outils de build)
  RUN apt-get update && apt-get install -y \
      libpq5 \
      curl \
      && rm -rf /var/lib/apt/lists/* \
      && apt-get clean

  # Créer l'utilisateur non-root pour la sécurité
  RUN groupadd -r taskmanager && \
      useradd -r -g taskmanager -d /app -s /bin/bash taskmanager

  # Copier le venv compilé depuis le stage builder
  COPY --from=builder /build/venv /app/venv

  # Copier le code applicatif
  WORKDIR /app
  COPY --chown=taskmanager:taskmanager . .

  # Variables d'environnement par défaut
  ENV PATH="/app/venv/bin:$PATH" \
      FLASK_APP=run.py \
      FLASK_ENV=production \
      PORT=5000 \
      PYTHONDONTWRITEBYTECODE=1 \
      PYTHONUNBUFFERED=1

  # Passer à l'utilisateur non-root
  USER taskmanager

  # Port exposé (documentation, pas une vraie ouverture de port)
  EXPOSE 5000

  # Health check Docker
  HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
      CMD curl -f http://localhost:5000/health || exit 1

  # Commande de démarrage
  CMD ["gunicorn", \
       "--workers", "3", \
       "--timeout", "120", \
       "--bind", "0.0.0.0:5000", \
       "--access-logfile", "-", \
       "--error-logfile", "-", \
       "--log-level", "warning", \
       "run:app"]

────────────────────────────────────────────────────────────────────────────────
FICHIER: docker-compose.yml (développement local)
────────────────────────────────────────────────────────────────────────────────

  # ═══════════════════════════════════════════════════════════════════════════
  # docker-compose.yml — Environnement de développement local
  # Usage: docker compose up -d
  # ═══════════════════════════════════════════════════════════════════════════
  version: '3.9'

  services:
    # Application Flask
    api:
      build:
        context: .
        dockerfile: Dockerfile
        target: production   # Utiliser le stage de production (test en conditions réelles)
      ports:
        - "5000:5000"
      environment:
        - FLASK_ENV=development
        - FLASK_DEBUG=1
        - DATABASE_URL=postgresql://taskmanager:devpassword@postgres:5432/taskmanager
        - REDIS_URL=redis://redis:6379/0
        - SECRET_KEY=dev-only-not-secure
        - APP_VERSION=dev-local
      volumes:
        - .:/app   # Monter le code en volume -> rechargement automatique
      depends_on:
        postgres:
          condition: service_healthy
        redis:
          condition: service_healthy
      restart: unless-stopped

    # Base de données PostgreSQL
    postgres:
      image: postgres:15-alpine
      environment:
        POSTGRES_DB: taskmanager
        POSTGRES_USER: taskmanager
        POSTGRES_PASSWORD: devpassword
        POSTGRES_INITDB_ARGS: "--locale=fr_FR.UTF-8 --encoding=UTF8"
      ports:
        - "5432:5432"   # Exposer pour accès direct (pgAdmin, DBeaver...)
      volumes:
        - postgres_data:/var/lib/postgresql/data
      healthcheck:
        test: ["CMD-SHELL", "pg_isready -U taskmanager -d taskmanager"]
        interval: 10s
        timeout: 5s
        retries: 5

    # Cache Redis
    redis:
      image: redis:7-alpine
      ports:
        - "6379:6379"
      command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
      healthcheck:
        test: ["CMD", "redis-cli", "ping"]
        interval: 10s
        timeout: 5s
        retries: 3

    # Interface de visualisation Redis (optionnel, pratique en dev)
    redis-insight:
      image: redislabs/redisinsight:latest
      ports:
        - "8001:8001"
      depends_on:
        - redis

  volumes:
    postgres_data:
      driver: local

────────────────────────────────────────────────────────────────────────────────
FICHIER: .gitignore
────────────────────────────────────────────────────────────────────────────────

  # Python
  __pycache__/
  *.py[cod]
  *$py.class
  *.so
  .Python
  env/
  venv/
  ENV/
  .env
  !.env.example

  # Tests et couverture
  .pytest_cache/
  .coverage
  coverage.xml
  coverage_html/
  test-results.xml
  htmlcov/

  # Distribution Python
  build/
  dist/
  *.egg-info/
  .eggs/

  # IDE
  .vscode/
  .idea/
  *.swp
  *.swo
  .DS_Store
  Thumbs.db

  # Docker
  .dockerignore

  # Terraform
  infrastructure/terraform/**/.terraform/
  infrastructure/terraform/**/*.tfstate
  infrastructure/terraform/**/*.tfstate.backup
  infrastructure/terraform/**/*.tfplan
  infrastructure/terraform/**/.terraform.lock.hcl
  infrastructure/terraform/**/terraform.tfvars
  !infrastructure/terraform/**/terraform.tfvars.example

  # Ansible
  infrastructure/ansible/*.retry
  infrastructure/ansible/inventory/staging.ini.bak
  infrastructure/ansible/inventory/production.ini.bak
  # NE PAS ignorer vars/*.yml (ils sont chiffrés avec Vault)

  # Secrets et clés
  *.pem
  *.key
  .vault_pass*
  secrets/

  # Logs
  *.log
  logs/


================================================================================
PARTIE 12 — SÉCURITÉ AVANCÉE: DURCISSEMENT DU SERVEUR
================================================================================

  POURQUOI LA SÉCURITÉ?
  ──────────────────────
  Un serveur Ubuntu fraîchement créé a des vulnérabilités par défaut:
  -> SSH accessible sur le port 22 depuis Internet (cible d'attaques)
  -> Pas de protection contre les brute-force
  -> Services potentiellement exposés inutilement
  -> Kernel pas optimal pour la sécurité

  Ce que nous allons sécuriser:
  -> SSH: désactiver connexion root, changer le port, clés SSH uniquement
  -> Fail2ban: bannir les IPs qui échouent trop de connexions
  -> Kernel: paramètres sysctl de sécurité réseau
  -> Audit: logs de sécurité (auditd)
  -> ClamAV: antivirus (optionnel)
  -> Certificates SSL: Let's Encrypt avec HSTS

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 12.1 — ROLE ANSIBLE: HARDENING (DURCISSEMENT SÉCURITÉ)
────────────────────────────────────────────────────────────────────────────────

  ALICE CRÉE UN ROLE DÉDIÉ À LA SÉCURITÉ:
  ─────────────────────────────────────────
  cd infrastructure/ansible/
  ansible-galaxy role init roles/hardening

  nano roles/hardening/tasks/main.yml

  Contenu:

  ---
  # ═══════════════════════════════════════════════════════════════════════════
  # Role: hardening — Durcissement de sécurité du serveur Ubuntu
  # Appliqué APRÈS le role common, AVANT les roles applicatifs
  # ═══════════════════════════════════════════════════════════════════════════

  # ── 1. Mettre à jour le système complètement ─────────────────────────────
  - name: Mettre à jour tous les paquets système
    apt:
      upgrade: dist             # upgrade dist = plus complet que safe
      update_cache: yes
      cache_valid_time: 0       # Forcer le refresh

  # ── 2. Durcissement SSH ──────────────────────────────────────────────────
  # POURQUOI durcir SSH?
  # SSH est la porte d'entrée du serveur. Par défaut, il est trop permissif.
  # On le rend strict: clés SSH UNIQUEMENT, pas de connexion root, etc.
  - name: Configurer sshd_config durci
    template:
      src: sshd_config.j2
      dest: /etc/ssh/sshd_config
      owner: root
      group: root
      mode: '0600'
      backup: yes               # Sauvegarder l'ancien fichier (sécurité)
      validate: '/usr/sbin/sshd -t -f %s'
      # validate: vérifier la syntaxe AVANT d'écrire le fichier
      # Si syntaxe invalide -> ne pas écrire -> éviter de se bloquer dehors!
    notify: Redémarrer SSH
    # DANGER: si on se bloque dehors, impossible de corriger!
    # Toujours tester sur un serveur de test avant la prod.

  # ── 3. Paramètres sysctl de sécurité réseau ──────────────────────────────
  - name: Appliquer les paramètres sysctl de sécurité
    sysctl:
      name: "{{ item.key }}"
      value: "{{ item.value }}"
      state: present
      reload: yes
      sysctl_file: /etc/sysctl.d/99-taskmanager-security.conf
    loop:
      # Protection contre le IP spoofing
      - { key: "net.ipv4.conf.all.rp_filter",           value: "1" }
      - { key: "net.ipv4.conf.default.rp_filter",       value: "1" }
      # Désactiver l'acceptation des ICMP redirects (attaques man-in-the-middle)
      - { key: "net.ipv4.conf.all.accept_redirects",    value: "0" }
      - { key: "net.ipv6.conf.all.accept_redirects",    value: "0" }
      # Désactiver l'envoi des ICMP redirects
      - { key: "net.ipv4.conf.all.send_redirects",      value: "0" }
      # Protection contre SYN flood (attaques DDoS)
      - { key: "net.ipv4.tcp_syncookies",               value: "1" }
      - { key: "net.ipv4.tcp_max_syn_backlog",          value: "2048" }
      # Désactiver le ping broadcast (smurf attack)
      - { key: "net.ipv4.icmp_echo_ignore_broadcasts",  value: "1" }
      # Ignorer les erreurs ICMP bogus (malformées)
      - { key: "net.ipv4.icmp_ignore_bogus_error_responses", value: "1" }
      # Activer les logs des paquets "martiens" (IPs impossibles)
      - { key: "net.ipv4.conf.all.log_martians",        value: "1" }
      # Protection contre le time-wait assassination
      - { key: "net.ipv4.tcp_rfc1337",                  value: "1" }
      # Désactiver IPv6 si non utilisé (réduire la surface d'attaque)
      # { key: "net.ipv6.conf.all.disable_ipv6",         value: "1" }
      # (Commenté car on utilise IPv6 sur DigitalOcean)

  # ── 4. Paramètres sysctl pour les performances + sécurité ─────────────────
  - name: Optimiser les paramètres kernel pour Flask/Gunicorn
    sysctl:
      name: "{{ item.key }}"
      value: "{{ item.value }}"
      state: present
      sysctl_file: /etc/sysctl.d/99-taskmanager-perf.conf
    loop:
      # Augmenter la limite de fichiers ouverts
      - { key: "fs.file-max",                    value: "100000" }
      # Limite de connexions en attente (backlog)
      - { key: "net.core.somaxconn",             value: "65535" }
      # Buffer réseau
      - { key: "net.core.netdev_max_backlog",    value: "5000" }
      # Timeouts TCP
      - { key: "net.ipv4.tcp_fin_timeout",       value: "30" }
      - { key: "net.ipv4.tcp_keepalive_time",    value: "1200" }
      - { key: "net.ipv4.tcp_keepalive_probes",  value: "5" }
      - { key: "net.ipv4.tcp_keepalive_intvl",   value: "15" }
      # Ports éphémères pour les connexions sortantes
      - { key: "net.ipv4.ip_local_port_range",   value: "1024 65000" }

  # ── 5. Désactiver les modules kernel inutiles ─────────────────────────────
  - name: Désactiver les modules kernel non nécessaires
    copy:
      content: |
        # Modules désactivés pour des raisons de sécurité
        # (réduire la surface d'attaque du kernel)
        install cramfs /bin/true      # Filesystem rarement utilisé
        install squashfs /bin/true    # (garder si Docker/snap utilisés)
        install udf /bin/true         # Filesystem CD/DVD
        install dccp /bin/true        # Protocole réseau rarement utilisé
        install sctp /bin/true        # Protocole réseau rarement utilisé
        install rds /bin/true         # Protocole réseau
        install tipc /bin/true        # Protocole réseau interne cluster
      dest: /etc/modprobe.d/taskmanager-security.conf
      mode: '0644'

  # ── 6. Configurer auditd (logs de sécurité) ──────────────────────────────
  - name: Installer auditd
    apt:
      name:
        - auditd
        - audispd-plugins
      state: present

  - name: Configurer les règles d'audit
    copy:
      content: |
        # Règles auditd pour TaskManager
        # Logs toutes les commandes exécutées par root:
        -a always,exit -F arch=b64 -F euid=0 -S execve -k root_commands
        # Logs les modifications de fichiers sensibles:
        -w /etc/passwd -p wa -k passwd_changes
        -w /etc/shadow -p wa -k shadow_changes
        -w /etc/ssh/sshd_config -p wa -k sshd_config_changes
        -w /etc/sudoers -p wa -k sudoers_changes
        # Logs les connexions SSH:
        -w /var/log/auth.log -p wa -k auth_log
        # Logs les actions sur l'application:
        -w /opt/taskmanager -p wa -k taskmanager_changes
        -w /etc/systemd/system/taskmanager.service -p wa -k service_changes
        # Rendre les règles immuables (nécessite un reboot pour modifier):
        # -e 2
      dest: /etc/audit/rules.d/taskmanager.rules
      mode: '0640'
    notify: Redémarrer auditd

  - name: Démarrer et activer auditd
    service:
      name: auditd
      state: started
      enabled: yes

  # ── 7. Configurer unattended-upgrades (mises à jour auto de sécurité) ────
  - name: Installer unattended-upgrades
    apt:
      name:
        - unattended-upgrades
        - apt-listchanges
      state: present

  - name: Configurer unattended-upgrades
    copy:
      content: |
        // Configuration des mises à jour automatiques
        Unattended-Upgrade::Allowed-Origins {
            "${distro_id}:${distro_codename}-security";
        };
        Unattended-Upgrade::AutoFixInterruptedDpkg "true";
        Unattended-Upgrade::MinimalSteps "true";
        Unattended-Upgrade::InstallOnShutdown "false";
        Unattended-Upgrade::Remove-Unused-Dependencies "true";
        Unattended-Upgrade::Automatic-Reboot "false";  // Ne pas redémarrer auto
        Unattended-Upgrade::Automatic-Reboot-Time "02:00";
      dest: /etc/apt/apt.conf.d/50unattended-upgrades
      mode: '0644'

  - name: Activer les mises à jour automatiques
    copy:
      content: |
        APT::Periodic::Update-Package-Lists "1";
        APT::Periodic::Unattended-Upgrade "1";
        APT::Periodic::AutocleanInterval "7";
      dest: /etc/apt/apt.conf.d/20auto-upgrades
      mode: '0644'

  # ── 8. Configurer des limites de ressources système ───────────────────────
  - name: Configurer les limites système globales
    copy:
      content: |
        # /etc/security/limits.conf — Limites système pour TaskManager
        # Format: <domain> <type> <item> <value>

        # Utilisateur deploy: limites élevées pour l'API
        deploy soft nofile 65536
        deploy hard nofile 65536
        deploy soft nproc  4096
        deploy hard nproc  4096

        # Utilisateur postgres: limites pour la DB
        postgres soft nofile 65536
        postgres hard nofile 65536

        # Global: limites raisonnables pour tous
        * soft core 0           # Désactiver les core dumps (infos sensibles)
        * hard core 0
      dest: /etc/security/limits.conf
      mode: '0644'

  nano roles/hardening/templates/sshd_config.j2

  Contenu:

  # sshd_config — Généré par Ansible (configuration durcie)
  # AVERTISSEMENT: ce fichier est géré par Ansible. NE PAS modifier manuellement.

  # ── Protocole et port ──────────────────────────────────────────────────────
  Port {{ ssh_port | default(22) }}
  Protocol 2                          # SSH v2 uniquement (v1 est vulnérable)
  AddressFamily inet                  # IPv4 seulement (simplifier les règles firewall)

  # ── Authentification ──────────────────────────────────────────────────────
  PermitRootLogin no                  # JAMAIS se connecter en tant que root via SSH
  PasswordAuthentication no           # UNIQUEMENT clés SSH (pas de mot de passe)
  ChallengeResponseAuthentication no
  PubkeyAuthentication yes
  AuthorizedKeysFile .ssh/authorized_keys

  # ── Sécurité des sessions ─────────────────────────────────────────────────
  MaxAuthTries 3                      # 3 essais max (réduit le brute-force)
  MaxSessions 10
  LoginGraceTime 60                   # 60 secondes pour s'authentifier
  ClientAliveInterval 300             # Déconnecter si inactif > 5 minutes
  ClientAliveCountMax 3
  TCPKeepAlive yes

  # ── Restrictions d'accès ──────────────────────────────────────────────────
  AllowUsers ubuntu deploy            # UNIQUEMENT ces utilisateurs via SSH
  DenyUsers root                      # Bloquer root explicitement

  # ── Forwarding ────────────────────────────────────────────────────────────
  AllowTcpForwarding no              # Désactiver le port forwarding (sécurité)
  X11Forwarding no                   # Pas d'interface graphique (serveur headless)
  AllowAgentForwarding yes           # Permettre SSH agent forwarding (pour git)
  PermitTunnel no

  # ── Logging ────────────────────────────────────────────────────────────────
  LogLevel VERBOSE                   # Logs détaillés pour auditd
  SyslogFacility AUTH

  # ── Algorithmes cryptographiques (modernes et sécurisés) ──────────────────
  # Désactiver les algorithmes faibles (MD5, SHA1, arcfour...)
  KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group14-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512
  Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
  MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com

  # ── SFTP ─────────────────────────────────────────────────────────────────
  Subsystem sftp /usr/lib/openssh/sftp-server

  nano roles/hardening/handlers/main.yml

  Contenu:
  ---
  - name: Redémarrer SSH
    service:
      name: sshd
      state: restarted
    # ATTENTION: si la config est incorrecte, vous perdez l'accès SSH!
    # Toujours tester avec --check avant d'appliquer en production.

  - name: Redémarrer auditd
    service:
      name: auditd
      state: restarted

  nano roles/hardening/defaults/main.yml

  Contenu:
  ---
  ssh_port: 22
  # En production, certains changent le port SSH (ex: 2222) pour éviter le scan.
  # Si vous changez le port, mettre à jour:
  # 1. Le firewall DigitalOcean (Terraform)
  # 2. ansible.cfg (ansible_port: 2222)
  # 3. ~/.ssh/config (Port 2222 pour ce serveur)


================================================================================
PARTIE 13 — MONITORING AVANCÉ: GRAFANA DASHBOARDS ET ALERTES
================================================================================

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 13.1 — INSTALLER ET CONFIGURER GRAFANA AVEC ANSIBLE
────────────────────────────────────────────────────────────────────────────────

  POURQUOI GRAFANA?
  ──────────────────
  Prometheus collecte les métriques (chiffres bruts).
  Grafana les transforme en graphiques visuels.
  Ensemble: vous voyez en temps réel:
  -> CPU, RAM, Disque du serveur
  -> Nombre de requêtes/seconde sur l'API Flask
  -> Temps de réponse moyen (p50, p95, p99)
  -> Taux d'erreur (5xx, 4xx)
  -> Connexions actives PostgreSQL
  -> Utilisation mémoire Redis
  -> Alertes email/Slack si un seuil est dépassé

  ALICE COMPLÈTE LE ROLE MONITORING:
  ────────────────────────────────────
  nano roles/monitoring/tasks/main.yml
  # (Ajouter à la fin du fichier existant)

  Contenu à ajouter:

  # ── Grafana: interface de visualisation ─────────────────────────────────
  - name: Ajouter la clé GPG Grafana
    apt_key:
      url: https://packages.grafana.com/gpg.key
      state: present

  - name: Ajouter le dépôt Grafana
    apt_repository:
      repo: "deb https://packages.grafana.com/oss/deb stable main"
      state: present
      filename: grafana

  - name: Installer Grafana
    apt:
      name: grafana
      state: present
      update_cache: yes

  - name: Configurer Grafana
    template:
      src: grafana.ini.j2
      dest: /etc/grafana/grafana.ini
      owner: grafana
      group: grafana
      mode: '0640'
    notify: Redémarrer Grafana

  - name: Configurer la datasource Prometheus dans Grafana
    copy:
      content: |
        apiVersion: 1
        datasources:
          - name: Prometheus
            type: prometheus
            url: http://localhost:{{ prometheus_port }}
            access: proxy
            isDefault: true
            uid: taskmanager-prometheus
            jsonData:
              scrapeInterval: 15s
              timeInterval: 15s
              httpMethod: POST
      dest: /etc/grafana/provisioning/datasources/prometheus.yml
      owner: grafana
      group: grafana
      mode: '0640'
    notify: Redémarrer Grafana

  - name: Configurer le dashboard Grafana (TaskManager Overview)
    copy:
      content: |
        apiVersion: 1
        providers:
          - name: "TaskManager"
            folder: "TaskManager"
            type: file
            options:
              path: /var/lib/grafana/dashboards
      dest: /etc/grafana/provisioning/dashboards/taskmanager.yml
      owner: grafana
      group: grafana
      mode: '0640'

  - name: Créer le dossier des dashboards
    file:
      path: /var/lib/grafana/dashboards
      state: directory
      owner: grafana
      group: grafana
      mode: '0755'

  - name: Déployer le dashboard Flask API
    template:
      src: dashboard_flask.json.j2
      dest: /var/lib/grafana/dashboards/flask_api.json
      owner: grafana
      group: grafana
      mode: '0644'
    notify: Redémarrer Grafana

  - name: Démarrer et activer Grafana
    service:
      name: grafana-server
      state: started
      enabled: yes
    when: monitoring_enabled | bool

  nano roles/monitoring/templates/grafana.ini.j2

  Contenu:

  # grafana.ini — Généré par Ansible

  [server]
  protocol = http
  http_port = {{ grafana_port }}
  domain = {{ domain }}
  root_url = %(protocol)s://%(domain)s:%(http_port)s/grafana/
  serve_from_sub_path = true

  [security]
  admin_user = admin
  admin_password = {{ grafana_admin_password | default('changeme123!') }}
  secret_key = {{ grafana_secret_key | default('SW2YcwTIb9zpOOhoPsMm') }}
  disable_gravatar = true

  [auth.anonymous]
  enabled = false   # Pas d'accès anonyme

  [snapshots]
  external_enabled = false   # Désactiver le partage externe

  [analytics]
  reporting_enabled = false  # Pas de télémétrie vers Grafana Labs
  check_for_updates = false

  [log]
  mode = file
  level = warn
  filters = rendering:debug

  [database]
  type = sqlite3   # SQLite pour un seul serveur (PostgreSQL si cluster Grafana)
  path = grafana.db

  [alerting]
  enabled = true

  [unified_alerting]
  enabled = true

  nano roles/monitoring/handlers/main.yml
  # (Ajouter)

  - name: Redémarrer Grafana
    service:
      name: grafana-server
      state: restarted

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 13.2 — CONFIGURER LES ALERTES PROMETHEUS
────────────────────────────────────────────────────────────────────────────────

  nano roles/monitoring/templates/prometheus_alerts.yml.j2

  Contenu:

  # prometheus_alerts.yml — Règles d'alerte Prometheus
  # Ces règles surveillent l'application et déclenchent des alertes
  # quand des seuils sont dépassés.

  groups:
    # ── Alertes Infrastructure ────────────────────────────────────────────
    - name: infrastructure
      rules:
        - alert: ServerCPUHigh
          expr: 100 - (avg by(instance) (irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 80
          for: 5m           # Déclencher seulement si la condition dure 5 minutes
          labels:
            severity: warning
            environment: "{{ flask_env }}"
          annotations:
            summary: "CPU élevé sur {{ '{{' }} $labels.instance {{ '}}' }}"
            description: "CPU à {{ '{{' }} $value | humanize {{ '}}' }}% depuis 5 minutes."

        - alert: ServerCPUCritical
          expr: 100 - (avg by(instance) (irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 95
          for: 2m
          labels:
            severity: critical
          annotations:
            summary: "CPU CRITIQUE sur {{ '{{' }} $labels.instance {{ '}}' }}"
            description: "CPU à {{ '{{' }} $value | humanize {{ '}}' }}%. Action immédiate requise."

        - alert: ServerMemoryHigh
          expr: (node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / node_memory_MemTotal_bytes * 100 > 85
          for: 5m
          labels:
            severity: warning
          annotations:
            summary: "Mémoire élevée"
            description: "RAM utilisée à {{ '{{' }} $value | humanize {{ '}}' }}%"

        - alert: DiskSpaceLow
          expr: (node_filesystem_size_bytes{mountpoint="/"} - node_filesystem_free_bytes{mountpoint="/"}) / node_filesystem_size_bytes{mountpoint="/"} * 100 > 80
          for: 10m
          labels:
            severity: warning
          annotations:
            summary: "Espace disque faible"
            description: "Disque / utilisé à {{ '{{' }} $value | humanize {{ '}}' }}%"

        - alert: ServerDown
          expr: up == 0
          for: 1m
          labels:
            severity: critical
          annotations:
            summary: "Serveur inaccessible!"
            description: "Le serveur {{ '{{' }} $labels.instance {{ '}}' }} ne répond plus."

    # ── Alertes Application Flask ─────────────────────────────────────────
    - name: flask_application
      rules:
        - alert: FlaskHighErrorRate
          expr: rate(flask_http_request_total{status=~"5.."}[5m]) / rate(flask_http_request_total[5m]) * 100 > 5
          for: 2m
          labels:
            severity: warning
          annotations:
            summary: "Taux d'erreur élevé sur l'API Flask"
            description: "{{ '{{' }} $value | humanize {{ '}}' }}% des requêtes retournent 5xx"

        - alert: FlaskHighResponseTime
          expr: histogram_quantile(0.95, rate(flask_http_request_duration_seconds_bucket[5m])) > 1
          for: 5m
          labels:
            severity: warning
          annotations:
            summary: "Temps de réponse élevé (p95 > 1s)"
            description: "95% des requêtes prennent plus de {{ '{{' }} $value | humanize {{ '}}' }}s"

        - alert: FlaskDown
          expr: up{job="flask_app"} == 0
          for: 1m
          labels:
            severity: critical
          annotations:
            summary: "L'API Flask est DOWN!"
            description: "L'application Flask ne répond plus aux métriques Prometheus."

    # ── Alertes PostgreSQL ────────────────────────────────────────────────
    - name: postgresql
      rules:
        - alert: PostgreSQLDown
          expr: up{job="postgresql"} == 0
          for: 1m
          labels:
            severity: critical
          annotations:
            summary: "PostgreSQL est DOWN!"

        - alert: PostgreSQLTooManyConnections
          expr: pg_stat_activity_count > pg_settings_max_connections * 0.8
          for: 5m
          labels:
            severity: warning
          annotations:
            summary: "PostgreSQL: trop de connexions"
            description: "{{ '{{' }} $value {{ '}}' }} connexions actives (> 80% du max)"

        - alert: PostgreSQLSlowQueries
          expr: rate(pg_stat_user_tables_seq_scan[5m]) > 10
          for: 5m
          labels:
            severity: warning
          annotations:
            summary: "PostgreSQL: scans séquentiels fréquents"
            description: "Trop de full table scans. Vérifier les index manquants."

  # Configurer Alertmanager pour envoyer les alertes vers Slack:
  nano roles/monitoring/templates/alertmanager.yml.j2

  Contenu:

  # alertmanager.yml — Géré par Ansible
  global:
    slack_api_url: "{{ slack_webhook_url }}"
    resolve_timeout: 5m

  route:
    group_by: ['alertname', 'environment']
    group_wait: 30s         # Attendre 30s avant d'envoyer (regrouper les alertes)
    group_interval: 5m      # Minimum 5 minutes entre 2 groupes d'alertes
    repeat_interval: 4h     # Renvoyer les alertes non résolues toutes les 4h
    receiver: slack-default

    # Routes spécifiques selon la sévérité:
    routes:
      - match:
          severity: critical
        receiver: slack-critical
        group_wait: 10s      # Les alertes critiques sont envoyées plus vite

  receivers:
    - name: slack-default
      slack_configs:
        - channel: '#alerts-{{ flask_env }}'
          title: '{{ "{{" }} .GroupLabels.alertname {{ "}}" }} — {{ flask_env }}'
          text: |
            {{ "{{" }} range .Alerts {{ "}}" }}
            *{{ "{{" }} .Annotations.summary {{ "}}" }}*
            {{ "{{" }} .Annotations.description {{ "}}" }}
            {{ "{{" }} end {{ "}}" }}
          send_resolved: true   # Notifier aussi quand l'alerte est résolue

    - name: slack-critical
      slack_configs:
        - channel: '#alerts-critical'
          title: '[ALERTE] CRITIQUE: {{ "{{" }} .GroupLabels.alertname {{ "}}" }}'
          color: danger
          send_resolved: true
      # En production: ajouter aussi PagerDuty ou email pour les alertes critiques


================================================================================
PARTIE 14 — DEBUGGING ET TROUBLESHOOTING: TOUT CE QUI PEUT MAL SE PASSER
================================================================================

  CETTE PARTIE EST CRUCIALE.
  ────────────────────────────
  Tout débutant rencontre des erreurs. Cette partie liste les ERREURS LES PLUS
  FRÉQUENTES avec leur cause exacte et leur solution.

────────────────────────────────────────────────────────────────────────────────
ERREURS TERRAFORM
────────────────────────────────────────────────────────────────────────────────

  ── ERREUR 1: Provider initialization ─────────────────────────────────────

  SYMPTÔME:
  Error: Inconsistent dependency lock file
  The following dependency selections recorded in the lock file are inconsistent
  with the current configuration: provider registry.terraform.io/digitalocean/digitalocean

  CAUSE:
  Le fichier .terraform.lock.hcl est obsolète ou manquant.

  SOLUTION:
  terraform init -upgrade
  # Met à jour le lock file avec les versions actuelles

  ── ERREUR 2: Token invalide ───────────────────────────────────────────────

  SYMPTÔME:
  Error: Error creating Droplet: POST https://api.digitalocean.com/v2/droplets:
  401 Unable to authenticate you.

  CAUSE:
  Le token DigitalOcean est invalide, expiré, ou manquant.

  SOLUTION:
  # Vérifier que la variable est bien définie:
  echo $TF_VAR_do_token
  # Si vide: re-exporter:
  export TF_VAR_do_token="dop_v1_VOTRE_VRAI_TOKEN"
  # Vérifier le token sur: cloud.digitalocean.com/account/api/tokens

  ── ERREUR 3: State lock ──────────────────────────────────────────────────

  SYMPTÔME:
  Error: Error acquiring the state lock
  Lock Info:
    ID:        abc123
    Path:      terraform.state
    Operation: apply
    Who:       alice@alice-macbook
    Created:   2024-01-15 10:30:00

  CAUSE:
  Un autre terraform apply est en cours, ou le précédent s'est planté sans
  libérer le verrou.

  SOLUTION:
  # SEULEMENT si vous êtes CERTAIN qu'aucun autre apply ne tourne:
  terraform force-unlock abc123
  # DANGER: ne jamais faire si quelqu'un d'autre fait un apply!

  ── ERREUR 4: Ressource déjà existante ───────────────────────────────────

  SYMPTÔME:
  Error: Error creating Droplet: POST .../droplets:
  422 name taskmanager-staging already exists

  CAUSE:
  Un serveur avec ce nom existe déjà sur DigitalOcean mais n'est pas dans le state
  Terraform (créé manuellement ou state perdu).

  SOLUTION:
  # Option A: Importer la ressource existante dans le state:
  # Trouver l'ID sur DigitalOcean dashboard:
  terraform import digitalocean_droplet.taskmanager_staging 12345678

  # Option B: Supprimer manuellement sur DigitalOcean et relancer:
  # cloud.digitalocean.com -> Droplets -> taskmanager-staging -> Destroy

  ── ERREUR 5: terraform plan montre "destroy" inattendu ──────────────────

  SYMPTÔME:
  Plan: 1 to add, 0 to change, 1 to destroy.
  # digitalocean_droplet.taskmanager_staging must be replaced

  CAUSE FRÉQUENTE:
  Changement d'un attribut qui force la recréation (forcenew = true dans le provider).
  Exemples: changer la région, l'image, ou la taille du Droplet.

  SOLUTION:
  # Lire ATTENTIVEMENT le plan:
  terraform plan -detailed-exitcode
  # Chercher les lignes "forces new resource" pour comprendre pourquoi.
  # Si destruction en prod: planifier une fenêtre de maintenance.

  # Pour éviter une destruction accidentelle: utiliser -target pour vérifier:
  terraform plan -target=digitalocean_droplet.taskmanager_staging

  ── ERREUR 6: Module introuvable ─────────────────────────────────────────

  SYMPTÔME:
  Error: Module not installed
  Module "staging_server" is not yet installed; run "terraform init" to install
  all modules required by this configuration.

  CAUSE:
  Un module a été ajouté dans main.tf mais terraform init n'a pas été relancé.

  SOLUTION:
  terraform init
  # Toujours relancer init après l'ajout d'un nouveau module ou provider.

────────────────────────────────────────────────────────────────────────────────
ERREURS ANSIBLE
────────────────────────────────────────────────────────────────────────────────

  ── ERREUR 1: SSH UNREACHABLE ─────────────────────────────────────────────

  SYMPTÔME:
  taskmanager-staging | UNREACHABLE! => {
      "msg": "Failed to connect to the host via ssh",
      "unreachable": true
  }

  CAUSES ET SOLUTIONS:
  A) Le serveur vient d'être créé et n'est pas encore prêt (2-3 minutes de boot)
     -> Attendre et relancer

  B) La clé SSH n'est pas dans authorized_keys
     -> Vérifier que la clé a été ajoutée lors de terraform apply (ssh_keys dans le Droplet)
     -> Ou ajouter manuellement via l'interface DigitalOcean (Recovery console)

  C) L'IP dans l'inventaire est incorrecte
     -> terraform output staging_ip  -> Vérifier et corriger staging.ini

  D) Le firewall bloque le port 22
     -> Vérifier les règles dans Terraform (inbound port 22)
     -> DigitalOcean -> Networking -> Firewalls -> vérifier les règles

  E) Le serveur n'accepte plus SSH après durcissement (sshd_config)
     -> Utiliser la console de recovery DigitalOcean (accès web direct au serveur)
     -> Corriger sshd_config via la console
     -> Relancer ssh avec: sudo service sshd restart

  DIAGNOSTIC:
  ssh -v -i ~/.ssh/id_ed25519_github ubuntu@164.90.154.23
  # -v = verbose: affiche chaque étape de la connexion
  # Chercher dans le output:
  # "Permission denied (publickey)" -> problème de clé
  # "Connection refused" -> SSH ne tourne pas ou firewall
  # "Connection timed out" -> serveur injoignable (IP incorrecte ou réseau)

  ── ERREUR 2: Vault Decryption ────────────────────────────────────────────

  SYMPTÔME:
  ERROR! Decryption failed (no vault secrets would decrypt)
  Vault encrypted file: vars/staging_secrets.yml

  CAUSE:
  Le mot de passe du vault est incorrect ou le fichier vault n'est pas spécifié.

  SOLUTION:
  # Vérifier que le fichier vault existe:
  cat ~/.vault_pass_staging
  # Si inexistant: le créer avec le bon mot de passe

  # Spécifier le fichier vault explicitement:
  ansible-playbook site.yml --vault-password-file ~/.vault_pass_staging

  # Vérifier que le fichier est bien chiffré avec ce mot de passe:
  ansible-vault view vars/staging_secrets.yml --vault-password-file ~/.vault_pass_staging

  ── ERREUR 3: Python non trouvé ───────────────────────────────────────────

  SYMPTÔME:
  taskmanager-staging | FAILED! => {
      "msg": "/usr/bin/python: No such file or directory"
  }

  CAUSE:
  Le serveur utilise python3 mais Ansible cherche python.

  SOLUTION:
  # Dans inventory/staging.ini ou group_vars/all.yml:
  ansible_python_interpreter=/usr/bin/python3

  # Ou auto-détection:
  ansible_python_interpreter=auto

  ── ERREUR 4: Become sudo failed ─────────────────────────────────────────

  SYMPTÔME:
  taskmanager-staging | FAILED! => {
      "msg": "Missing sudo password"
  }

  CAUSE:
  Ansible tente d'utiliser sudo mais l'utilisateur ne peut pas sudo sans mot de passe.

  SOLUTION:
  # Vérifier que l'utilisateur ubuntu peut sudo sans mot de passe sur Ubuntu:
  ssh ubuntu@164.90.154.23 "sudo id"
  # Sur Ubuntu/DigitalOcean: ubuntu a sudo sans mot de passe par défaut -> OK

  # Dans ansible.cfg:
  [privilege_escalation]
  become = True
  become_method = sudo
  become_user = root
  become_ask_pass = False

  ── ERREUR 5: Module pip absent ──────────────────────────────────────────

  SYMPTÔME:
  TASK [flask_app : Installer les dépendances Python] ***
  fatal: [taskmanager-staging]: FAILED! => {
      "msg": "Could not import Python library: psutil"
  }

  CAUSE:
  Le module pip Ansible nécessite certains paquets Python sur le serveur cible.

  SOLUTION:
  # S'assurer que python3-pip est installé (role common doit le faire):
  ansible -i inventory/staging.ini all -m apt \
    -a "name=python3-pip state=present" --become

  ── ERREUR 6: Template Jinja2 erreur ─────────────────────────────────────

  SYMPTÔME:
  TASK [flask_app : Créer le fichier .env] ***
  fatal: [taskmanager-staging]: FAILED! => {
      "msg": "AnsibleUndefinedVariable: 'flask_secret_key' is undefined"
  }

  CAUSE:
  La variable flask_secret_key n'est pas définie. Elle devrait être dans le vault.
  Possible si le vault n'est pas chargé ou si la variable a un nom différent.

  SOLUTION:
  # Vérifier que la variable est dans le vault:
  ansible-vault view vars/staging_secrets.yml --vault-password-file ~/.vault_pass_staging | grep flask_secret_key

  # Vérifier que vars_files inclut bien le vault dans le playbook:
  # vars_files:
  #   - vars/staging_secrets.yml

  # Vérifier qu'aucune faute de frappe dans le nom de variable.

  ── ERREUR 7: Port en conflit ─────────────────────────────────────────────

  SYMPTÔME:
  TASK [nginx : Démarrer et activer Nginx] ***
  fatal: [taskmanager-staging]: FAILED! => {
      "msg": "Job for nginx.service failed ... (code=exited, status=1/FAILURE)"
  }

  DIAGNOSTIC:
  ansible -i inventory/staging.ini web -m shell \
    -a "sudo nginx -t && sudo journalctl -u nginx -n 20 --no-pager"

  # Output probable:
  # nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use)

  CAUSE:
  Un autre service occupe déjà le port 80 (Apache, une ancienne instance Nginx...).

  SOLUTION:
  ansible -i inventory/staging.ini web -m shell \
    -a "sudo ss -tlnp | grep :80"
  # -> Identifier quel processus occupe le port 80
  ansible -i inventory/staging.ini web -m shell \
    -a "sudo systemctl stop apache2 && sudo systemctl disable apache2"

  ── ERREUR 8: PostgreSQL connexion refusée ────────────────────────────────

  SYMPTÔME:
  TASK [postgresql : Créer la base de données TaskManager] ***
  fatal: [taskmanager-staging]: FAILED! => {
      "msg": "unable to connect to database: FATAL: Peer authentication failed for user 'taskmanager'"
  }

  CAUSE:
  Le module postgresql_db utilise l'authentification "peer" par défaut.
  Ça fonctionne pour l'utilisateur postgres, mais pas pour taskmanager.

  SOLUTION:
  # S'assurer que la tâche utilise become_user: postgres:
  - name: Créer la base de données
    postgresql_db:
      name: taskmanager
      state: present
    become: yes
    become_user: postgres   # <- OBLIGATOIRE

────────────────────────────────────────────────────────────────────────────────
DEBUGGING AVANCÉ: COMMENT DIAGNOSTIQUER UNE PANNE EN PRODUCTION
────────────────────────────────────────────────────────────────────────────────

  PROCESSUS DE DIAGNOSTIC (5 étapes):
  ─────────────────────────────────────

  ÉTAPE 1: L'API ne répond plus. Quoi vérifier en premier?
  ──────────────────────────────────────────────────────────

  # Depuis votre machine locale:
  curl -v http://164.90.154.23/health
  # -> Connection refused: le serveur est down OU Nginx ne tourne pas
  # -> 502 Bad Gateway: Nginx tourne mais Flask ne répond pas
  # -> 200 OK: l'API répond (le problème est ailleurs)

  ÉTAPE 2: Se connecter en SSH et vérifier les services:
  ────────────────────────────────────────────────────────

  ssh ubuntu@164.90.154.23

  # Vérifier tous les services en une commande:
  for service in taskmanager nginx postgresql@15-main redis-server; do
    status=$(systemctl is-active $service)
    echo "$service: $status"
  done

  # Output attendu (tout OK):
  # taskmanager: active
  # nginx: active
  # postgresql@15-main: active
  # redis-server: active

  # Si taskmanager est "failed":
  sudo systemctl status taskmanager
  sudo journalctl -u taskmanager -n 50 --no-pager
  # Chercher: "ImportError", "ModuleNotFoundError", "error loading"

  ÉTAPE 3: Vérifier les logs applicatifs:
  ─────────────────────────────────────────

  # Logs Flask/Gunicorn:
  sudo tail -100 /var/log/taskmanager/error.log
  sudo tail -100 /var/log/taskmanager/access.log

  # Logs Nginx:
  sudo tail -50 /var/log/nginx/taskmanager/error.log
  sudo tail -50 /var/log/nginx/access.log

  # Logs système (kernel, OOM killer...):
  sudo dmesg | tail -50
  sudo journalctl -k -n 50   # kernel messages

  # Logs PostgreSQL:
  sudo tail -50 /var/log/postgresql/postgresql-15-main.log

  ÉTAPE 4: Vérifier les ressources système:
  ───────────────────────────────────────────

  # RAM:
  free -h
  # Si RAM saturée -> OOM killer peut avoir tué Gunicorn
  # Vérifier: sudo dmesg | grep -i "killed process"

  # CPU:
  top -b -n1 | head -30
  # ou: htop (si installé)

  # Disque:
  df -h
  # Si disque plein -> PostgreSQL ne peut pas écrire -> crash
  du -sh /var/log/* | sort -h | tail -10   # Trouver les gros dossiers

  # Connexions réseau:
  ss -tlnp   # Ports en écoute
  ss -tnp    # Connexions établies

  ÉTAPE 5: Vérifier la base de données:
  ──────────────────────────────────────

  sudo -u postgres psql -d taskmanager
  # Dans psql:
  \l           # Lister les bases de données
  \dt          # Lister les tables
  SELECT COUNT(*) FROM tasks;   # Vérifier les données
  \q           # Quitter

  # Connexions actives:
  SELECT pid, usename, application_name, state, query
  FROM pg_stat_activity
  WHERE datname = 'taskmanager';

  # Vérifier les locks:
  SELECT pid, relation::regclass, mode, granted
  FROM pg_locks l
  JOIN pg_class c ON c.oid = l.relation
  WHERE NOT granted;

  ── UTILISER ANSIBLE POUR DIAGNOSTIQUER À DISTANCE ──────────────────────────

  # Sans SSH interactif — diagnostiquer via Ansible ad-hoc:

  # État de tous les services:
  ansible -i inventory/production.ini all -m shell \
    -a "systemctl is-active taskmanager nginx postgresql@15-main redis-server" \
    --become

  # 50 dernières lignes de logs Flask:
  ansible -i inventory/production.ini web -m shell \
    -a "journalctl -u taskmanager -n 50 --no-pager" --become

  # Utilisation des ressources:
  ansible -i inventory/production.ini all -m shell \
    -a "echo '=RAM='; free -h; echo '=CPU='; uptime; echo '=DISK='; df -h /" \
    --become

  # Redémarrer Flask si nécessaire (sans modifier le code):
  ansible -i inventory/production.ini web -m service \
    -a "name=taskmanager state=restarted" --become


================================================================================
PARTIE 15 — FLUX DE TRAVAIL D'ÉQUIPE COMPLET JOUR PAR JOUR
================================================================================

  CE CHAPITRE RÉPOND À: "Que fait chaque membre de l'équipe, chaque jour?"
  On suit la vie du projet sur 4 semaines, de la création à la première release.

════════════════════════════════════════════════════════════════════════════════
SEMAINE 1 — MISE EN PLACE DE L'INFRASTRUCTURE (ALICE)
════════════════════════════════════════════════════════════════════════════════

────────────────────────────────────────────────────────────────────────────────
JOUR 1 — ALICE INITIALISE TOUT (8h -> 17h)
────────────────────────────────────────────────────────────────────────────────

  MATIN (8h -> 12h): Installation et configuration initiale

  ── 8h00: Alice crée le compte DigitalOcean ──────────────────────────────────
  1. Aller sur cloud.digitalocean.com -> Create Account
  2. Ajouter une carte bancaire (les serveurs coûtent ~$24/mois en staging)
  3. Settings -> Billing -> Mettre une alerte à $50/mois (prévenir les dépassements)
  4. API -> Personal Access Tokens -> Generate New Token "terraform-taskmanager"
  5. Sauvegarder le token dans un gestionnaire de mots de passe (1Password, Bitwarden)

  ── 8h30: Alice ajoute sa clé SSH sur DigitalOcean ───────────────────────────
  # Vérifier si une clé SSH existe déjà:
  ls -la ~/.ssh/
  # Si id_ed25519 n'existe pas: en créer une:
  ssh-keygen -t ed25519 -C "alice@equipe.com" -f ~/.ssh/id_ed25519_github
  # Appuyer Entrée deux fois (pas de passphrase pour l'automatisation)

  # Copier la clé publique:
  cat ~/.ssh/id_ed25519_github.pub
  # -> ssh-ed25519 AAAA... alice@equipe.com

  # Sur DigitalOcean: Settings -> Security -> Add SSH Key
  # Coller la clé publique, nom: "alice-macbook-2024"

  ── 9h00: Installer Terraform ─────────────────────────────────────────────────
  brew tap hashicorp/tap
  brew install hashicorp/tap/terraform
  terraform --version
  # -> Terraform v1.7.0

  brew install tflint tfsec terraform-docs
  terraform -install-autocomplete

  ── 9h30: Installer Ansible ─────────────────────────────────────────────────
  python3 -m venv ~/ansible-venv
  source ~/ansible-venv/bin/activate
  pip install ansible==9.0.0 ansible-lint
  ansible --version
  # -> ansible [core 2.16.0]

  # Ajouter dans ~/.zshrc pour activer automatiquement:
  echo "source ~/ansible-venv/bin/activate" >> ~/.zshrc

  ── 10h00: Créer la structure du projet ────────────────────────────────────────
  cd ~/projects/
  git clone git@github.com:equipe/taskmanager.git
  cd taskmanager

  # Créer toute la structure infrastructure/:
  mkdir -p infrastructure/terraform/environments/{staging,production}
  mkdir -p infrastructure/terraform/modules/{server,network,database}
  mkdir -p infrastructure/ansible/inventory/dynamic
  mkdir -p infrastructure/ansible/roles/{common,python,flask_app,postgresql,redis,nginx,monitoring,hardening}
  mkdir -p infrastructure/ansible/{group_vars,host_vars,vars,templates}
  mkdir -p .github/workflows

  # Committer la structure vide:
  git checkout -b infra/setup-infrastructure
  git add infrastructure/ .github/
  git commit -m "feat(infra): créer la structure infrastructure as code"
  git push -u origin infra/setup-infrastructure

  ── 10h30: Alice écrit les fichiers Terraform ──────────────────────────────────
  # (Écrire tous les fichiers de la Partie 3 de ce guide)
  # main.tf, variables.tf, outputs.tf, terraform.tfvars

  ── 11h30: Premier terraform init et plan ─────────────────────────────────────
  cd infrastructure/terraform/environments/staging/
  export TF_VAR_do_token="dop_v1_..."
  terraform init
  terraform validate
  terraform plan
  # -> Plan: 6 to add, 0 to change, 0 to destroy.
  # Alice lit chaque ressource dans le plan.

  APRÈS-MIDI (13h -> 17h): Déploiement et configuration Ansible

  ── 13h30: Alice lance terraform apply ────────────────────────────────────────
  terraform apply
  # -> Enter a value: yes
  # Durée: ~2 minutes
  # -> Apply complete! Resources: 6 added.
  # -> staging_ip = "164.90.154.23"

  # Tester la connexion SSH:
  ssh ubuntu@164.90.154.23
  # -> Welcome to Ubuntu 22.04.3 LTS
  exit

  ── 14h00: Alice écrit les fichiers Ansible ───────────────────────────────────
  # (Écrire tous les fichiers de la Partie 4 de ce guide)
  # ansible.cfg, inventory/staging.ini, group_vars/, vars/, roles/...

  ── 15h00: Créer les secrets Vault ────────────────────────────────────────────
  cd infrastructure/ansible/
  ansible-vault create vars/staging_secrets.yml
  # Mot de passe vault: "staging-vault-password-2024"
  # Contenu: flask_secret_key, postgres_password, redis_password, slack_webhook_url

  echo "staging-vault-password-2024" > ~/.vault_pass_staging
  chmod 600 ~/.vault_pass_staging

  ── 15h30: Test Ansible ping ──────────────────────────────────────────────────
  ansible -i inventory/staging.ini all -m ping
  # -> taskmanager-staging | SUCCESS => {"ping": "pong"}

  ── 16h00: Premier déploiement complet ────────────────────────────────────────
  ansible-playbook -i inventory/staging.ini site.yml \
    --vault-password-file ~/.vault_pass_staging \
    --check --diff
  # Alice lit tout le output. Tout semble correct.

  ansible-playbook -i inventory/staging.ini site.yml \
    --vault-password-file ~/.vault_pass_staging
  # Durée: ~12 minutes
  # -> PLAY RECAP: ok=47   changed=31   unreachable=0    failed=0

  ── 16h30: Tests de l'API ─────────────────────────────────────────────────────
  curl http://164.90.154.23/health
  # -> {"status": "healthy", "database": "ok"}

  curl -X POST http://164.90.154.23/tasks \
    -H "Content-Type: application/json" \
    -d '{"title": "Premier test en staging!", "priority": "high"}'
  # -> {"id": 1, "title": "Premier test en staging!", ...}

  ── 17h00: Alice commit et ouvre une PR ───────────────────────────────────────
  git add -A
  git commit -m "feat(infra): infrastructure staging opérationnelle"
  git push

  # Ouvre une PR: "feat: Setup infrastructure Terraform + Ansible"
  # Description: "Staging déployé sur 164.90.154.23. Health check: OK."
  # Assigne Bob et Claire pour review.

  # Message Slack à l'équipe:
  # "[BRAVO] L'infra staging est en place! API dispo: http://164.90.154.23/health
  #  Pour déployer: cd infrastructure/ansible && ansible-playbook deploy.yml
  #  Docs: voir infrastructure/README.md"

────────────────────────────────────────────────────────────────────────────────
JOUR 2 — BOB ET CLAIRE CONFIGURENT LEUR ENVIRONNEMENT (8h -> 12h)
────────────────────────────────────────────────────────────────────────────────

  BOB (Ubuntu 22.04) — 8h:
  ──────────────────────────

  ── Installer Terraform ─────────────────────────────────────────────────────
  sudo apt-get update && sudo apt-get install -y gnupg software-properties-common
  wget -O- https://apt.releases.hashicorp.com/gpg | \
    sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
  echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \
    https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \
    sudo tee /etc/apt/sources.list.d/hashicorp.list
  sudo apt-get update && sudo apt-get install terraform
  terraform --version   # -> Terraform v1.7.0

  ── Installer Ansible ────────────────────────────────────────────────────────
  sudo apt-add-repository --yes --update ppa:ansible/ansible
  sudo apt-get install ansible
  ansible --version

  ── Cloner le projet ─────────────────────────────────────────────────────────
  cd ~/projects/
  git clone git@github.com:equipe/taskmanager.git
  cd taskmanager

  # Bob n'a PAS le mot de passe vault. Alice le lui communique de façon sécurisée
  # (via 1Password, Signal, ou en personne — PAS par email ou Slack!)
  echo "staging-vault-password-2024" > ~/.vault_pass_staging
  chmod 600 ~/.vault_pass_staging

  # Bob crée sa clé SSH:
  ssh-keygen -t ed25519 -C "bob@equipe.com" -f ~/.ssh/id_ed25519
  cat ~/.ssh/id_ed25519.pub   # -> Bob envoie ça à Alice via Slack

  # Alice ajoute la clé de Bob sur DigitalOcean (Settings -> Security -> SSH Keys)
  # ET met à jour terraform pour l'inclure dans le Droplet:
  # data "digitalocean_ssh_key" "bob" { name = "bob-ubuntu-2024" }
  # Puis terraform apply pour l'ajouter au serveur staging existant
  # (ou: ssh ubuntu@164.90.154.23 et ajouter dans ~/.ssh/authorized_keys)

  ── Bob teste sa connexion ────────────────────────────────────────────────────
  cd infrastructure/ansible/
  ansible -i inventory/staging.ini all \
    --private-key ~/.ssh/id_ed25519 \
    -m ping
  # -> taskmanager-staging | SUCCESS => {"ping": "pong"}

  # Bob peut maintenant faire des déploiements en staging!

  CLAIRE (Windows 11 + WSL2) — 9h:
  ───────────────────────────────────

  ── Installer WSL2 si pas déjà fait ──────────────────────────────────────────
  # Dans PowerShell (admin):
  wsl --install -d Ubuntu-22.04
  # Redémarrer si demandé
  # Créer l'utilisateur Ubuntu: claire / mot de passe fort

  ── Dans WSL2 Ubuntu: installer les outils ────────────────────────────────────
  # (Les mêmes commandes que Bob ci-dessus)
  # Terraform, Ansible, git clone...

  ── Claire teste le health check staging ──────────────────────────────────────
  curl http://164.90.154.23/health
  # -> {"status": "healthy", ...}

  # Claire crée également quelques tâches de test:
  curl -X POST http://164.90.154.23/tasks \
    -H "Content-Type: application/json" \
    -d '{"title": "Test Claire", "priority": "medium"}'

════════════════════════════════════════════════════════════════════════════════
SEMAINE 2 — DÉVELOPPEMENT ET DÉPLOIEMENTS FRÉQUENTS
════════════════════════════════════════════════════════════════════════════════

────────────────────────────────────────────────────────────────────────────────
JOUR 6 — SCÉNARIO: BOB DÉPLOIE SA FEATURE FILTRAGE DATES
────────────────────────────────────────────────────────────────────────────────

  CONTEXTE:
  ──────────
  Bob a terminé feature/5-filtrage-dates (les routes ?from=...&to=...).
  Il a ouvert une PR, Alice et Claire ont reviewé et approuvé.
  La PR est mergée dans develop. GitHub Actions déploie automatiquement en staging.

  CE QUE GITHUB ACTIONS FAIT (automatiquement après le merge):
  ─────────────────────────────────────────────────────────────
  1. Workflow deploy-staging.yml déclenché sur push dans develop
  2. Job terraform-staging: pas de changements .tf -> skip terraform
  3. Job ansible-staging:
     a. Configure SSH
     b. Détecte que Flask est déjà installé -> deploy.yml (pas site.yml)
     c. git pull -> nouveau code de Bob
     d. pip install -> aucun changement (requirements.txt inchangé)
     e. Redémarre Flask gracefully
     f. Tests d'intégration
  4. Notification Slack: "[OK] staging déployé — version: develop"

  CE QUE BOB VOIT SUR GITHUB (onglet Actions):
  ──────────────────────────────────────────────
  [BLACK_CIRCLE] deploy-staging.yml (running...)
    ├── [OK] [CONSTRUCTION] Terraform Staging (skipped: no changes)
    └── [SYNC] [DOC] Ansible Deploy Staging
          ├── [OK] Checkout
          ├── [OK] Configurer SSH
          ├── [OK] Ansible Ping
          ├── [OK] Détecter type de déploiement (update)
          ├── [OK] Ansible deploy.yml
          └── [OK] Tests d'intégration staging

  # Durée totale: ~3 minutes

  BOB TESTE SA FEATURE SUR STAGING:
  ───────────────────────────────────
  curl "http://164.90.154.23/tasks?from=2024-01-01&to=2024-12-31"
  # -> {"tasks": [...], "total": 3}  [OK]

  curl "http://164.90.154.23/tasks?from=invalid-date"
  # -> {"error": "Format 'from' invalide. Utiliser YYYY-MM-DD"}, 400  [OK]

  # Bob commente sur la PR: "[OK] Testé en staging. Filtrage fonctionne comme attendu."

────────────────────────────────────────────────────────────────────────────────
JOUR 8 — SCÉNARIO: ALICE CRÉE L'ENVIRONNEMENT DE PRODUCTION
────────────────────────────────────────────────────────────────────────────────

  CONTEXTE:
  ──────────
  Staging est stable depuis 1 semaine. L'équipe est prête pour la production.

  ── 9h00: Alice crée les ressources Terraform production ──────────────────────
  cd infrastructure/terraform/environments/
  cp -r staging production
  cd production/

  # Adapter terraform.tfvars.example et créer terraform.tfvars:
  cp terraform.tfvars.example terraform.tfvars
  nano terraform.tfvars
  # Changer: droplet_size = "s-4vcpu-8gb", enable_backups = true, etc.

  terraform init
  terraform plan
  # Vérifier attentivement: que du "create", aucun "destroy"

  terraform apply
  # -> production_ip = "165.22.80.45"
  # -> Durée: ~3 minutes

  ── 9h30: Alice crée les secrets production ────────────────────────────────────
  cd infrastructure/ansible/
  ansible-vault create vars/prod_secrets.yml \
    --vault-password-file ~/.vault_pass_prod

  # Communique le mot de passe vault prod à l'équipe de façon sécurisée
  # (PAS le même que staging!)

  ── 10h00: Déploiement complet sur production ──────────────────────────────────
  # Dry-run OBLIGATOIRE avant la prod:
  ansible-playbook -i inventory/production.ini site.yml \
    --vault-password-file ~/.vault_pass_prod \
    --check --diff

  # Alice lit tout. Après validation:
  ansible-playbook -i inventory/production.ini site.yml \
    --vault-password-file ~/.vault_pass_prod
  # Durée: ~15 minutes

  # Configurer les secrets GitHub pour la production:
  # Settings -> Environments -> production -> Variables
  # Ajouter: ANSIBLE_VAULT_PASSWORD_PROD = le mot de passe prod

  ── 11h00: Alice crée le tag v1.0.0 ───────────────────────────────────────────
  git checkout main
  git tag -a v1.0.0 -m "Version 1.0.0: première release production"
  git push origin v1.0.0

  # GitHub Actions workflow deploy-production.yml se déclenche:
  # -> Attente d'approbation d'Alice dans l'interface GitHub
  # Settings -> Environments -> production -> Required reviewers = Alice

  # Sur GitHub: Actions -> deploy-production -> "Review deployments"
  # Alice clique "Approve and deploy"

  # 10 minutes plus tard:
  # [OK] Notification Slack: "[BRAVO] PRODUCTION v1.0.0 déployée!"
  # curl https://api.equipe.com/health -> {"status": "healthy"}

════════════════════════════════════════════════════════════════════════════════
SEMAINE 3 — MAINTENANCE ET INCIDENTS
════════════════════════════════════════════════════════════════════════════════

────────────────────────────────────────────────────────────────────────────────
JOUR 15 — SCÉNARIO: ALERTE DISQUE PLEIN (2h du matin)
────────────────────────────────────────────────────────────────────────────────

  Alice reçoit une alerte Prometheus à 2h03:
  "[ATTENTION] DiskSpaceLow: Disque / utilisé à 82% sur taskmanager-production"

  ── 8h00: Alice diagnostique le lendemain matin ────────────────────────────────
  ssh ubuntu@165.22.80.45

  # Trouver ce qui prend de la place:
  df -h /
  # -> /dev/sda1   40G   33G   7.0G  83% /

  du -sh /var/log/* | sort -h | tail -10
  # -> 28G /var/log/postgresql   <- Les logs PostgreSQL sont énormes!

  # Vérifier le paramètre de rotation des logs PostgreSQL:
  sudo cat /etc/postgresql/15/main/postgresql.conf | grep log_rotation
  # -> log_rotation_age = 1d   (rotation quotidienne)
  # Mais les vieux fichiers ne sont pas supprimés automatiquement!

  ── Solution: Alice corrige le role PostgreSQL ────────────────────────────────
  # Ajouter dans roles/postgresql/tasks/main.yml:
  # - name: Configurer la rotation des logs PostgreSQL
  #   cron:
  #     name: "Supprimer les vieux logs PostgreSQL"
  #     minute: "0"
  #     hour: "2"
  #     job: "find /var/log/postgresql -name '*.log' -mtime +7 -delete"
  #     user: postgres

  # Appliquer le fix immédiatement:
  ansible-playbook -i inventory/production.ini site.yml \
    --vault-password-file ~/.vault_pass_prod \
    --tags postgresql

  # Nettoyer manuellement les anciens logs:
  ansible -i inventory/production.ini all \
    -m shell \
    -a "find /var/log/postgresql -name '*.log' -mtime +7 -delete" \
    --become --become-user postgres

  # Vérifier que le disque est libéré:
  ansible -i inventory/production.ini all -m shell -a "df -h /"
  # -> /dev/sda1   40G   10G   28G  27% /   [OK]

  # Committer le fix:
  git checkout -b fix/postgresql-log-rotation
  # (Modifier roles/postgresql/tasks/main.yml)
  git add -A && git commit -m "fix: configurer la rotation automatique des logs PostgreSQL"
  git push -u origin fix/postgresql-log-rotation
  # Ouvrir une PR -> merger dans develop -> déployer en staging -> vérifier -> merger dans main

────────────────────────────────────────────────────────────────────────────────
JOUR 17 — SCÉNARIO: ROLLBACK D'URGENCE EN PRODUCTION
────────────────────────────────────────────────────────────────────────────────

  CONTEXTE:
  ──────────
  Bob a déployé v1.1.0 en production. Les utilisateurs signalent que
  la pagination ne fonctionne plus (toujours retourne 0 résultats).
  Le bug est dans la branche main mais pas en staging (car les données
  de test staging ne déclenchent pas le bug).

  ── 14h25: Alice détecte le problème ─────────────────────────────────────────
  # Grafana montre: taux d'erreur 0% mais les requêtes retournent toujours total=0
  # Les logs Flask montrent une erreur SQLAlchemy sur les queries paginées

  # Décision de l'équipe: rollback immédiat vers v1.0.0

  ── 14h30: Alice exécute le rollback ──────────────────────────────────────────
  cd infrastructure/ansible/
  ansible-playbook -i inventory/production.ini rollback.yml \
    --vault-password-file ~/.vault_pass_prod \
    -e "rollback_to=v1.0.0 skip_confirmation=true"

  CE QUE VOUS VOYEZ:
  ───────────────────
  TASK [Afficher la version actuelle] ***
  ok: [taskmanager-prod] => {"msg": "Version actuelle: v1.1.0"}

  TASK [Rollback du code vers v1.0.0] ***
  changed: [taskmanager-prod]

  TASK [Redémarrer l'application avec l'ancien code] ***
  changed: [taskmanager-prod]

  TASK [Vérifier que l'application est de nouveau opérationnelle] ***
  ok: [taskmanager-prod]

  TASK [Résumé du rollback] ***
  ok: [taskmanager-prod] => {
      "msg": "[OK] ROLLBACK RÉUSSI!\nRetour: v1.1.0 -> v1.0.0\nSanté: healthy"
  }

  # Durée totale: 2 minutes 15 secondes.

  ── 14h35: Alice notifie l'équipe ─────────────────────────────────────────────
  # Sur Slack:
  # "[OK] Rollback effectué. Production de retour sur v1.0.0.
  #  Bob: le bug de pagination doit être corrigé avant de re-déployer v1.1.0.
  #  Claire: peut-tu écrire un test qui couvre ce cas?"

  ── Le lendemain: Bob corrige le bug, Claire écrit le test ────────────────────
  # Bob: fix dans feature/fix-pagination-v110
  # Claire: test dans tests/test_tasks.py:
  #   def test_pagination_returns_correct_total(client, multiple_tasks):
  #       response = client.get('/tasks?page=1&per_page=2')
  #       assert response.get_json()['total'] == 5  # total = toutes les tâches
  #       assert len(response.get_json()['tasks']) == 2  # pas total!
  # PR mergée -> v1.1.1 créée -> déployée -> tests passent [OK]

════════════════════════════════════════════════════════════════════════════════
SEMAINE 4 — OPTIMISATION ET SCALE
════════════════════════════════════════════════════════════════════════════════

────────────────────────────────────────────────────────────────────────────────
JOUR 22 — SCÉNARIO: AJOUTER LA PRODUCTION EU (2ème RÉGION)
────────────────────────────────────────────────────────────────────────────────

  CONTEXTE:
  ──────────
  Le client a des utilisateurs en Asie. L'API en France (fra1) a des latences
  élevées pour eux (250ms+). Il faut un serveur en Asie (sgp1 = Singapore).

  ALICE CRÉE UN 3ÈME ENVIRONNEMENT EN 20 MINUTES:
  ─────────────────────────────────────────────────

  cd infrastructure/terraform/environments/
  cp -r production production-asia
  cd production-asia/

  nano terraform.tfvars
  # Changer UNE SEULE VARIABLE:
  # region = "sgp1"   # Singapore
  # (Tout le reste est identique à la production EU)

  terraform init
  terraform plan
  # -> Plan: 7 to add, 0 to change, 0 to destroy.
  # Un serveur identique va être créé en Asie!

  terraform apply
  # -> production_asia_ip = "68.183.201.55"

  # Créer l'inventaire Ansible:
  cat > infrastructure/ansible/inventory/production_asia.ini << 'EOF'
  [web]
  taskmanager-prod-asia ansible_host=68.183.201.55

  [db]
  taskmanager-prod-asia ansible_host=68.183.201.55

  [production:children]
  web
  db

  [all:vars]
  ansible_user=ubuntu
  ansible_python_interpreter=/usr/bin/python3
  EOF

  # Configurer et déployer:
  ansible-playbook -i inventory/production_asia.ini site.yml \
    --vault-password-file ~/.vault_pass_prod

  # Configurer le DNS (round-robin ou GeoDNS):
  # Dans terraform environments/production-asia/main.tf:
  resource "digitalocean_record" "api_asia" {
    domain = "equipe.com"
    type   = "A"
    name   = "api-asia"       # api-asia.equipe.com
    value  = digitalocean_droplet.taskmanager_production_asia.ipv4_address
    ttl    = 300
  }

  # api-asia.equipe.com répond depuis Singapore: latence ~15ms pour l'Asie!

────────────────────────────────────────────────────────────────────────────────
COMMANDES DE RÉFÉRENCE RAPIDE (ANTI-SÈCHE)
────────────────────────────────────────────────────────────────────────────────

  ══ TERRAFORM ══════════════════════════════════════════════════════════════════

  terraform init                     # Initialiser (télécharger providers/modules)
  terraform init -upgrade            # Mettre à jour les providers
  terraform validate                 # Vérifier la syntaxe
  terraform fmt -recursive .         # Formater les fichiers HCL
  terraform plan                     # Aperçu des changements (dry-run)
  terraform plan -out=myplan         # Sauvegarder le plan
  terraform apply                    # Appliquer les changements (avec confirmation)
  terraform apply -auto-approve      # Appliquer sans confirmation (CI/CD)
  terraform apply -target=RESOURCE   # Appliquer seulement une ressource
  terraform destroy                  # Détruire toutes les ressources
  terraform destroy -target=RESOURCE # Détruire seulement une ressource
  terraform output                   # Voir les outputs
  terraform output -raw NOM          # Valeur sans guillemets
  terraform state list               # Lister les ressources dans le state
  terraform state show RESOURCE      # Détails d'une ressource
  terraform state rm RESOURCE        # Retirer du state (sans détruire)
  terraform state mv OLD NEW         # Renommer dans le state
  terraform import RESOURCE ID       # Importer une ressource existante
  terraform workspace list           # Lister les workspaces
  terraform workspace new NOM        # Créer un workspace
  terraform workspace select NOM     # Changer de workspace
  terraform graph | dot -Tpng > g.png # Visualiser les dépendances
  terraform force-unlock LOCK_ID     # Libérer un lock (urgence)

  ══ ANSIBLE ════════════════════════════════════════════════════════════════════

  ansible all -m ping                # Tester la connexion à tous les hôtes
  ansible web -m shell -a "CMD"      # Exécuter une commande sur les web
  ansible all -m setup               # Voir les facts de tous les hôtes
  ansible all -m setup -a "filter=ansible_mem*"  # Filtrer les facts

  ansible-playbook site.yml          # Exécuter le playbook principal
  ansible-playbook site.yml --check  # Dry-run (ne rien modifier)
  ansible-playbook site.yml --diff   # Afficher les diffs
  ansible-playbook site.yml --check --diff  # Les deux
  ansible-playbook site.yml --tags nginx    # Seulement les tâches taguées nginx
  ansible-playbook site.yml --skip-tags monitoring  # Tout sauf monitoring
  ansible-playbook site.yml --limit web     # Seulement les hôtes web
  ansible-playbook site.yml -v       # Verbose (niveau 1)
  ansible-playbook site.yml -vvv     # Très verbose (débogage)
  ansible-playbook site.yml -e "key=value"  # Surcharger une variable

  ansible-vault create FILE          # Créer un fichier chiffré
  ansible-vault edit FILE            # Modifier un fichier chiffré
  ansible-vault view FILE            # Voir le contenu chiffré
  ansible-vault encrypt FILE         # Chiffrer un fichier existant
  ansible-vault decrypt FILE         # Déchiffrer (ATTENTION: ne pas committer!)
  ansible-vault rekey FILE           # Changer le mot de passe du vault
  ansible-vault encrypt_string 'val' --name 'varname'  # Chiffrer une valeur inline

  ansible-galaxy role init roles/NOM # Créer la structure d'un role
  ansible-galaxy install -r requirements.yml  # Installer les roles communautaires
  ansible-galaxy collection install COLLECTION  # Installer une collection
  ansible-galaxy role list           # Lister les roles installés

  ══ COMMANDES DE DIAGNOSTIC ════════════════════════════════════════════════════

  # Depuis la machine locale:
  ssh ubuntu@IP                      # Connexion SSH au serveur
  ssh -v ubuntu@IP                   # SSH verbose (debug connexion)
  curl -v http://IP/health           # Test HTTP verbose

  # Via Ansible (sans SSH interactif):
  ansible all -m service_facts                    # État de tous les services
  ansible web -m shell -a "df -h /"              # Espace disque
  ansible web -m shell -a "free -h"              # RAM
  ansible web -m shell -a "tail -50 /var/log/taskmanager/error.log"  # Logs Flask
  ansible web -m shell -a "journalctl -u taskmanager -n 20 --no-pager"  # Logs systemd
  ansible web -m service -a "name=taskmanager state=restarted" --become  # Redémarrer

  ══ FLUX GIT STANDARD POUR DÉPLOIEMENTS ════════════════════════════════════════

  # Développement:
  git checkout develop
  git checkout -b feature/ma-feature
  # ... développer ...
  git add -A && git commit -m "feat: ma feature"
  git push -u origin feature/ma-feature
  # -> Ouvrir PR vers develop
  # -> CI passe (tests, lint, terraform plan)
  # -> Review par Alice/Claire
  # -> Merge dans develop
  # -> GitHub Actions déploie automatiquement en STAGING

  # Release en production:
  git checkout main
  git merge develop
  git tag -a v1.2.0 -m "Version 1.2.0"
  git push origin main --tags
  # -> GitHub Actions déclenche deploy-production.yml
  # -> Alice approuve dans GitHub
  # -> Déploiement automatique en PRODUCTION

  # Rollback d'urgence:
  ansible-playbook -i inventory/production.ini rollback.yml \
    --vault-password-file ~/.vault_pass_prod \
    -e "rollback_to=v1.1.0 skip_confirmation=true"

================================================================================
  FIN DU GUIDE TERRAFORM & ANSIBLE — PARTIES 10 À 15
================================================================================

  CE QUE VOUS AVEZ MAINTENANT:
  ─────────────────────────────
  [OK] Infrastructure complète sur DigitalOcean (VPC, Firewall, Droplets, DNS, Volumes)
  [OK] 7 roles Ansible (common, postgresql, redis, flask_app, nginx, monitoring, hardening)
  [OK] 4 playbooks (site.yml, deploy.yml, rollback.yml, maintenance.yml)
  [OK] CI/CD GitHub Actions complet (ci.yml, deploy-staging.yml, deploy-production.yml)
  [OK] Application Flask complète avec tests (run.py, models.py, routes, tests)
  [OK] Sécurité avancée (hardening SSH, sysctl, auditd, unattended-upgrades)
  [OK] Monitoring (Prometheus + Grafana + alertes Slack)
  [OK] Scénarios complets (déploiements, rollbacks, pannes, scaling)
  [OK] Debugging et troubleshooting exhaustif

  PROCHAINES ÉTAPES SUGGÉRÉES:
  ─────────────────────────────
  -> Ajouter PostgreSQL managé (DigitalOcean Managed Database) pour plus de résilience
  -> Configurer un Load Balancer DigitalOcean devant plusieurs serveurs Flask
  -> Mettre en place la Disaster Recovery (backups S3, tests de restauration)
  -> Explorer Terraform CDK (écrire l'infra en Python au lieu de HCL)
  -> Migrer vers Kubernetes (EKS/GKE) quand l'équipe grandit

================================================================================