Sägetstrasse 18, 3123 Belp, Switzerland +41 79 173 36 84 info@ict.technology

    Terraform @ Scale - Partie 6c : dépendances entre modules pour utilisateurs avancés (et masochistes)

    "Au diable la visibilité - tant que ça marche !"

    C’est avec cet état d’esprit que la plupart des équipes de Platform Engineering courent à leur perte. C’est un peu comme cuisiner les yeux bandés dans une cuisine inconnue : ça peut fonctionner un moment, mais lorsque ça brûle, c’est pour de bon.

    Et rien n’est pire que de voir des visages désemparés lorsque tout part en fumée.

    Dans les deux parties précédentes, nous avons abordé la douleur liée aux dépendances. Voyons aujourd’hui quelles sont les options dont nous disposons pour repêcher un projet déjà tombé dans le puits avant qu’il ne soit trop tard.

     

    Moyens de représentation des dépendances entre modules

    Le premier problème dans les hiérarchies de modules complexes est la visibilité. Sur le papier, il existe pour cela terraform graph :


    terraform graph | dot -Tpng > graph.png

    La sortie de la commande terraform graph est bien intentionnée, mais une fois convertie en image, elle devient pratiquement inutilisable. Terraform ne prend en charge ni GraphML, ni GrapAR, ni TGF, ni GML, ni JSON, mais uniquement le format .dot, très répandu dans la génération de code et l’automatisation sous Linux, mais non supporté par les applications d’entreprise dédiées à la modélisation ou à l’analyse de données.

    Pour convertir un fichier .dot en image, vous devez installer le paquet graphviz, ce qui donne par exemple :

    Cela ressemble, selon moi, bien moins à mon infrastructure réseau qu’à un carambolage massif lors d’un Grand Prix de Formule 1 à Monaco, quand tous les pilotes tentent de prendre le premier virage en même temps. L’ergonomie, on repassera.

    Et même si, à force d’efforts et de réglages minutieux, vous parvenez à transformer ce fichier .dot en un schéma clair, vous n’en tirerez guère plus d’utilité qu’avec le fichier .dot original dans un environnement d’entreprise, c’est-à-dire aucune.

    Reste la commande terraform show, qui répertorie toutes les ressources. Mais elle ne permet pas de visualiser les dépendances non plus, ce qui limite fortement son intérêt.

    Voici donc un petit script qui tente de rendre le résultat de terraform graph un peu plus lisible directement dans la console. Grâce à ce script, vous pouvez immédiatement voir que le module X dépend du Remote State Y.
    Ce script s’appelle terraform-graph-visualizer.sh, et plutôt que de vous présenter ici plus de 200 lignes de code Bash, je vous donne l’URL du dépôt GitHub : GitHub - ICT-technology/terraform-graph-visualizer

    Le résultat du graphique ci-dessus ressemble alors à ceci (exemple fortement abrégé) :


    $ terraform graph | ~/bin/terraform-graph-visualizer.sh 
    ╔══════════════════════════════════════════════════════════╗
    ║ TERRAFORM GRAPH VISUALIZATION                            ║
    ╚══════════════════════════════════════════════════════════╝ Analyzing: stdin (terraform graph) GRAPH STATISTICS
    ═════════════════
    ├─ Total Nodes: 96
    ├─ Total Edges: 63
    ├─ Modules: 10
    └─ Data Sources: 3 TERRAFORM MODULES
    ══════════════════
    ├─ module.drg
    │ ├─ oci_core_drg_attachment.this
    │ └─ oci_core_drg.this ├─ module.drg_attachments
    │ ├─ oci_core_drg_attachment.this
    │ └─ oci_core_drg.this
    [...] DATA SOURCES
    ═════════════
    ├─ data.terraform_remote_state.icttechnology_buckets
    ├─ data.terraform_remote_state.icttechnology_compartments
    ├─ data.terraform_remote_state.tfstate-icttechnology_root DEPENDENCY RELATIONSHIPS
    ═════════════════════════
    ┌─ data.terraform_remote_state.icttechnology_compartments
    │ depends on:
    │ ├─ module.drg.oci_core_drg.this
    │ ├─ module.drg.oci_core_drg_attachment.this
    │ ├─ module.drg_attachments.oci_core_drg.this
    │ ├─ module.natgw.data.oci_core_services.services
    │ ├─ module.natgw.oci_core_vcn.this
    │ ├─ module.servicegw.data.oci_core_services.services
    │ ├─ module.servicegw.oci_core_nat_gateway.this
    │ ├─ module.servicegw.oci_core_vcn.this
    │ ├─ module.vcn.data.oci_core_services.services
    │ ├─ module.vcn.oci_core_nat_gateway.this
    │ └─ module.vcn.oci_core_vcn.this

    ┌─ module.drg.oci_core_drg_attachment.this
    │ depends on:
    │ ├─ module.drg_attachments.oci_core_drg_attachment.this
    │ ├─ module.route_table_bastion-classic.oci_core_route_table.this
    │ └─ module.route_table_vcn_FRA.oci_core_route_table.this
    [...] Graph visualization complete!

     Toujours pas très esthétique, mais au moins lisible. Et vous pouvez y voir non seulement les dépendances, mais également :

    • Les références Remote State
    • Les modules présentant un fan-in/fan-out important
    • Les points chauds des Data Sources

    Policy-as-Code : lorsque Sentinel empêche les plus grosses absurdités

    Avec Terraform Enterprise, Sentinel est disponible comme moteur Policy-as-Code. Sentinel garantit le respect des politiques internes et des exigences réglementaires - autrement dit, il empêche nos décisions vraiment stupides de causer des dégâts avant qu’elles ne se produisent.

    Sentinel Policy pour le versionnement des modules

    Avec une policy comme celle-ci, vous pouvez imposer que les modules soient épinglés sur des versions précises :


    import "tfconfig/v2" as tfconfig
    import "strings"
    
    // Heuristic: source is VCS if it has a git:: prefix OR a URI scheme present
    is_vcs = func(src) {
      strings.has_prefix(src, "git::") or strings.contains(src, "://")
    }
    
    // extract ref from the query (if present)
    ref_of = func(src) {
      // very simple extraction: everything after "?ref=" until the end
      // returns "" if not present
      idx = strings.index_of(src, "?ref=")
      idx >= 0 ? strings.substring(src, idx+5, -1) : ""
    }
    
    // Policy: all non-local modules must be versioned
    mandatory_versioning = rule {
      all tfconfig.module_calls as name, module {
        // local modules (./ or ../) are excluded
        if strings.has_prefix(module.source, "./") or strings.has_prefix(module.source, "../") {
          true
        } else if is_vcs(module.source) {
          // VCS modules: ref must be vX.Y.Z
          ref = ref_of(module.source)
          ref matches "^v[0-9]+\\.[0-9]+\\.[0-9]+$"
        } else {
          // Registry modules: version argument must be X.Y.Z
          module.version is not null and
          module.version matches "^[0-9]+\\.[0-9]+\\.[0-9]+$"
        }
      }
    }
    
    // Detailed validation for OCI modules
    validate_oci_modules = rule {
      all tfconfig.module_calls as name, module {
        if strings.contains(module.source, "oci-") {
          if is_vcs(module.source) {
            // strict path structure + SemVer tag
            module.source matches "^git::.+//base/oci-.+\\?ref=v[0-9]+\\.[0-9]+\\.[0-9]+$"
          } else {
            module.version is not null and
            module.version matches "^[0-9]+\\.[0-9]+\\.[0-9]+$"
          }
        } else {
          true
        }
      }
    }
    
    main = rule {
      mandatory_versioning and validate_oci_modules
    }

     La première étape est ainsi franchie.

    Une étape supplémentaire : listes d’autorisation et de refus via Policy-as-Code

    Si vous souhaitez aller plus loin, une policy comme celle qui suit ajoute une valeur supplémentaire. Elle vous permet d’introduire une liste d’autorisation (Allowlist) pour les sources de modules, ainsi qu’une liste d’exclusion (Denylist) pour les versions défectueuses ou vulnérables, le tout pouvant être complété de remarques « Advisory ». Cependant, vous ne devez pas reprendre cette policy telle quelle, mais l’adapter à vos propres cas d’usage, ainsi qu’à vos versions et avis de sécurité réels, sans quoi les règles ne s’appliqueront pas :


    import "tfconfig/v2" as tfconfig
    import "strings"
    
    // Helper functions
    is_local = func(src) {
      strings.has_prefix(src, "./") or strings.has_prefix(src, "../")
    }
    
    is_vcs = func(src) {
      strings.has_prefix(src, "git::") or strings.contains(src, "://")
    }
    
    ref_of = func(src) {
      i = strings.index_of(src, "?ref=")
      i >= 0 ? strings.substring(src, i+5, -1) : ""
    }
    
    // Allowlist of module sources
    allowed_module_sources = [
      "git::https://gitlab.ict.technology/modules//",
      "app.terraform.io/ict-technology/"
    ]
    
    // Banned lists separated for VCS tags and Registry versions
    banned_vcs_tags = [
      "v1.2.8",  // CVE-2024-12345
      "v1.5.2"   // Critical bug in networking
    ]
    
    banned_registry_versions = [
      "1.2.8",
      "1.5.2"
    ]
    
    // Rule: only allowed sources OR local modules
    only_allowed_sources = rule {
      all tfconfig.module_calls as _, m {
        is_local(m.source) or
        any allowed_module_sources as pfx { strings.has_prefix(m.source, pfx) }
      }
    }
    
    // Rule: no banned versions (VCS: tag in ref, Registry: version argument)
    no_banned_versions = rule {
      all tfconfig.module_calls as _, m {
        if is_local(m.source) {
          true
        } else if is_vcs(m.source) {
          t = ref_of(m.source)
          not (t in banned_vcs_tags)
        } else {
          // Registry
          m.version is string and not (m.version in banned_registry_versions)
        }
      }
    }
    
    // Advisory: warning for old major versions (set policy enforcement level to "advisory")
    warn_old_modules = rule {
      all tfconfig.module_calls as _, m {
        if is_local(m.source) {
          true
        } else if is_vcs(m.source) {
          r = ref_of(m.source)
          // If v1.*, then print warning, rule still passes
          strings.has_prefix(r, "v1.")
            ? (print("WARNING: Module", m.source, "uses v1.x - consider upgrading to v2.x")) or true
            : true
        } else {
          // Registry
          m.version is string and strings.has_prefix(m.version, "1.")
            ? (print("WARNING: Module", m.source, "uses 1.x - consider upgrading to 2.x")) or true
            : true
        }
      }
    }
    
    // Main rule: hard checks must pass
    main = rule {
      only_allowed_sources and no_banned_versions
    }

     

    Testing Framework : garde-fous automatisés dans Terraform 1.10+

    Les Sentinel Policies, c’est une chose – mais parfois, vous souhaitez valider des règles spécifiques directement là où vit le code du projet. Depuis Terraform 1.10, un Testing Framework natif est disponible à cet effet. Il permet d’écrire de petits tests ciblés qui renforcent vos modules contre les erreurs de configuration. Pas de moteur externe, pas de surcharge – simplement déclaratif, au sein même du projet.


    # tests/guardrails.tftest.hcl
    
    variables {
      max_creates = 50
      max_changes = 25
    }
    
    run "no_destroys_in_plan" {
      command = plan
    
      assert {
        condition = length([
          for rc in run.plan.resource_changes : rc
          if contains(rc.change.actions, "delete")
        ]) == 0
        error_message = "Plan contains deletions. Please split the change or use an approval workflow."
      }
    }
    
    run "cap_creates" {
      command = plan
    
      # Counts pure creates, not replacements
      assert {
        condition = length([
          for rc in run.plan.resource_changes : rc
          if contains(rc.change.actions, "create") && !contains(rc.change.actions, "delete")
        ]) <= var.max_creates
        error_message = format(
          "Too many new resources (%d > %d) in one run – split into smaller batches.",
          length([for rc in run.plan.resource_changes : rc if contains(rc.change.actions, "create") && !contains(rc.change.actions, "delete")]),
          var.max_creates
        )
      }
    }
    
    run "cap_changes" {
      command = plan
    
      assert {
        condition = length([
          for rc in run.plan.resource_changes : rc
          if contains(rc.change.actions, "update")
        ]) <= var.max_changes
        error_message = "Too many updates on existing resources - blast radius is too high."
      }
    }
    
    run "cap_replacements" {
      command = plan
    
      assert {
        condition = length([
          for rc in run.plan.resource_changes : rc
          if contains(rc.change.actions, "create") && contains(rc.change.actions, "delete")
        ]) == 0
        error_message = "Plan contains replacements (create+delete) - please review and minimize them."
      }
    }

    Vous atteignez ainsi deux objectifs à la fois : vos ingénieurs peuvent contrôler au niveau du projet que les modules sont proprement versionnés, et votre pipeline CI/CD bénéficie d’une ceinture de sécurité supplémentaire. Si quelque chose explose, ce sera au moins tôt, avant que cela ne touche la production.

    Mais une explication s’impose.

    Pourquoi cela fonctionne, c’est très simple :

    1. Terraform exécute un plan pour chaque run. Le Testing Framework met le résultat à disposition sous forme de données structurées via run.plan. Ce n’est pas un simple texte brut, mais un objet contenant notamment resource_changes.
    2. run.plan.resource_changes est une liste dans laquelle chaque élément correspond à une opération prévue sur une ressource. Chaque entrée possède un attribut change.actions, qui contient la liste des actions que Terraform prévoit pour cette ressource. Les valeurs possibles sont par exemple :
      • uniquement "create" lorsqu’une ressource est nouvellement créée,
      • uniquement "update" lorsqu’une ressource existante est modifiée,
      • "create" et "delete" ensemble lorsqu’une ressource est remplacée - c’est le remplacement classique.
    3. Les assertions sont de simples règles de comptage appliquées à cette liste :
      • no_destroys_in_plan filtre toutes les entrées dont les actions contiennent la chaîne "delete". Si l’ensemble n’est pas vide, le test échoue. Cela empêche les suppressions directes dans le plan.
      • cap_creates compte uniquement les créations réelles, c’est-à-dire les entrées contenant "create" sans "delete" en parallèle. Les remplacements ne sont donc pas considérés comme de simples créations. Le message d’erreur utilise format(...) pour éviter les problèmes de syntaxe avec les guillemets et afficher à la fois la valeur actuelle et la limite définie.
      • cap_changes limite le nombre de mises à jour, c’est-à-dire de modifications sur des ressources existantes.
      • cap_replacements intercepte explicitement les remplacements risqués, c’est-à-dire la combinaison "create" et "delete" sur la même ressource. Ces cas sont souvent les plus dangereux, car ils peuvent provoquer des interruptions ou des effets secondaires temporaires.
    4. Le bloc variables { ... } au début définit des seuils disponibles dans tous les runs. Vous pouvez ainsi adapter la policy à chaque pipeline ou environnement sans modifier la logique des tests.
    5. Le test ne requiert aucune construction interne spécifique au projet. Il s’appuie uniquement sur le modèle de plan standard que Terraform expose. Il est donc portable et (espérons-le) directement exploitable - à moins que je n’y aie encore glissé quelques erreurs par inadvertance.

    Si vous souhaitez en plus tester des échecs de préconditions attendus, c’est possible, mais cela nécessite des blocs check nommés dans votre code. Exemple :


    # In modules or root
    check "api_limits_reasonable" {
      assert {
        condition     = var.instance_count <= 200
        error_message = "Instance-Batch trop important."
      }
    }
    
    # and a test which deliberately violates the precondition
    
    run "expect_api_limit_breach" {
    command = plan
    variables {
    instance_count = 1000
    }
    expect_failures = [ check.api_limits_reasonable ]
    }

    Sans de tels checks nommés, un test correspondant n’aurait aucun effet. Pour le versionnement des modules ou les contraintes de version, le Testing Framework n’est pas adapté, car il se base sur le contenu du plan et non sur les métadonnées des modules. Pour cela, les Sentinel Policies ou un script CI analysant les sources de modules et les tags ?ref= ou les contraintes version sont plus appropriés.

    Quand le mal est déjà fait

    Supposons que votre State soit déjà corrompu et que des dépendances soient incorrectes. Il est peut-être temps alors d’envisager des mesures exceptionnelles. Voici une mesure d’urgence pour les plus téméraires, à nouveau uniquement sous forme de lien vers le dépôt Git : Github - ICTtechnology/module-state-recovery
    Ce script vous épargne le fastidieux travail manuel et repère automatiquement les modules qui causent des problèmes dans le Terraform State. Il crée consciencieusement une sauvegarde, exécute un plan, en produit la sortie au format JSON et examine de très près quels modules posent problème. Les ressources concernées sont affichées en aperçu. Si vous le souhaitez, vous pouvez même utiliser un fichier de mappage pour déplacer automatiquement et proprement des adresses de State avant de nettoyer le reste. En fonctionnement normal, rien de grave ne se produit - tout s’exécute en Dry-Run. Ce n’est que si vous lancez sciemment avec CONFIRM=1 et I_UNDERSTAND=YES que le script intervient réellement - mais alors de manière complète, avec la grosse hache. Cela en fait un outil utile pour la formation et les tests, afin de comprendre et de maîtriser les conflits de modules. Il n’est toutefois explicitement pas destiné à la production.

     

    Checklist pour des dépendances de modules robustes

    ✅ Stratégie de versions

    [ ] Semantic Versioning pour tous les modules de base et de service. Toute créativité ici se paiera double plus tard.
    [ ] Stratégie de pinning par niveau de module (Root, Service, Base). Celui qui écrit "latest" a déjà perdu.
    [ ] Une Compatibility Matrix des versions de modules existe et est tenue à jour.
    [ ] Un mécanisme de verrouillage des dépendances ou des scripts CI pour la vérification des versions sont intégrés.

    ✅ Gouvernance

    [ ] Le suivi des versions des modules est intégré à la CI/CD.
    [ ] Des Sentinel Policies pour les sources de modules autorisées et les plages de versions sont établies.
    [ ] Des Allow- et Deny-Lists pour les versions de modules défectueuses connues sont en place.
    [ ] Des notifications de breaking changes (Release Notes, mécanismes d’Advisory) sont établies.
    [ ] L’inventaire des modules est régulièrement actualisé et contrôlé.

    ✅ Monitoring

    [ ] L’Audit Logging pour les mises à jour de modules et les modifications de State est activé.
    [ ] Des systèmes d’alerte précoce pour les changements destructifs (analyse de plan, codes de sortie) sont mis en œuvre. S’ils n’existent pas, vous ne verrez l’explosion qu’en production.
    [ ] Un alerting en cas de modifications inattendues de modules ou de Dependency Drift est en place et actif.

    ✅ Recovery

    [ ] Une procédure pour la State Surgery et la migration d’adresses est définie, documentée et communiquée.
    [ ] Une stratégie de rollback pour les mises à jour de modules défaillantes est disponible.
    [ ] Des Emergency Response Playbooks pour les problèmes critiques de modules sont créés et communiqués.
    [ ] La formation de l’équipe au troubleshooting des dépendances de modules est établie. Oui, à partir d’une certaine taille de votre infrastructure automatisée, vous voulez tout cela. Au besoin, même contre la résistance de certains engineers qui se considèrent comme seniors.

    Car rien n’est pire que des visages désemparés lorsque tout part en fumée.