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

    Terraform @ Scale - Parte 6c: Dipendenze tra moduli per esperti (e masochisti)

    "Al diavolo la visibilità - l’importante è che funzioni!"

    È con questo atteggiamento che la maggior parte dei team di Platform Engineering si avvia verso la rovina. È come cucinare bendati in una cucina sconosciuta: per un po’ può anche andare bene, ma quando qualcosa brucia, allora brucia davvero.

    E nulla è peggiore dello sguardo smarrito sui volti quando tutto prende fuoco.

    Nei due capitoli precedenti il dolore delle dipendenze è stato il nostro tema principale. Oggi vediamo quali possibilità abbiamo per tirare fuori vivo un bambino che è già caduto nel pozzo.

     

    Possibilità di rappresentazione delle dipendenze tra moduli

    Il primo problema con gerarchie di moduli complesse è la visibilità. Su carta esiste per questo terraform graph:


    terraform graph | dot -Tpng > graph.png

    L’output di Terraform con terraform graph è ben intenzionato, ma una volta convertito in un’immagine risulta praticamente inutilizzabile. Terraform non supporta GraphML, GrapAR, TGF, GML o JSON, ma solo il formato .dot, che sebbene sia diffuso nel mondo Linux per la generazione di codice e l’automazione, non è supportato da alcuna applicazione enterprise di modellazione o analisi dati.

    Per convertire un file .dot in un’immagine è necessario il pacchetto graphviz, e il risultato appare ad esempio così:

    Direi che somiglia meno alla mia infrastruttura di rete e più a un incidente multiplo al Gran Premio di Monaco, quando subito dopo la partenza tutti cercano di imboccare la prima curva contemporaneamente. L’usabilità è un’altra cosa.

    E anche se riusciste, con molto lavoro manuale e fine tuning, a trasformare questo file .dot in uno schema leggibile, riuscireste comunque a farne tanto quanto con il file .dot originale generato in ambienti enterprise, cioè nulla.

    Rimane terraform show, poiché questo comando elenca tutte le risorse. Ma non serve molto, perché non è in grado di visualizzare le dipendenze.

    Ecco quindi un piccolo script che fa del suo meglio per rappresentare l’output di terraform graph direttamente nella shell. Con questo script vedrete subito che il modulo X dipende dallo stato remoto Y.
    Si chiama terraform-graph-visualizer.sh e invece di mostrarvi qui oltre 200 righe di codice Bash, vi lascio l’URL del repository su GitHub: GitHub - ICT-technology/terraform-graph-visualizer

    L’output del grafico sopra appare così (versione fortemente ridotta):


    $ 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!

     Ancora lontano dall’essere bello, ma almeno leggibile. E oltre alle semplici dipendenze, qui vedete subito anche:

    • Riferimenti a stati remoti
    • Moduli con elevato fan-in/fan-out
    • Hotspot di data source

    Policy-as-Code: Quando Sentinel impedisce le sciocchezze più gravi

    Con Terraform Enterprise è disponibile Sentinel come motore di Policy-as-Code. Sentinel garantisce che le policy aziendali e i requisiti normativi vengano rispettati - in altre parole, impedisce che le nostre decisioni davvero stupide possano causare danni prima ancora che si verifichino.

    Sentinel Policy per il version pinning dei moduli

    Con una policy come questa è possibile imporre che i moduli siano fissati su versioni specifiche:


    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
    }

     Il primo passo è fatto.

    Un passo in più: Allow- e Deny-List tramite Policy-as-Code

    Se vuole spingersi oltre, una policy come quella seguente può fornire ulteriore valore aggiunto. Con essa è possibile introdurre un’allowlist per le sorgenti dei moduli e una denylist per versioni difettose o vulnerabili, il tutto eventualmente accompagnato da avvisi “Advisory”. Non dovrebbe però adottare questa policy così com’è, ma adattarla ai propri casi d’uso, e soprattutto a versioni e advisory reali, altrimenti le regole non avranno effetto:


    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: Guardrail automatizzati in Terraform 1.10+

    Le Sentinel-Policies sono una cosa - ma a volte vuole verificare regole vicine al progetto direttamente dove vive il codice. Dalla versione 1.10, Terraform mette a disposizione il Testing Framework nativo. Con esso è possibile scrivere test piccoli e focalizzati che irrobustiscono i Suoi moduli contro le misconfigurazioni. Nessun motore esterno, nessun overhead - semplicemente dichiarativo nel progetto.


    # 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."
      }
    }

    Così prende due piccioni con una fava: i Suoi engineer possono controllare a livello di progetto che i moduli siano pulitamente versionati e la pipeline CI/CD ottiene una cintura di sicurezza aggiuntiva. Se qui qualcosa salta, allora almeno presto e prima che arrivi in produzione.

    Ma è opportuna una spiegazione.

    Perché funziona, in modo molto semplice:

    1. Terraform esegue un plan per ogni run. Il Testing Framework espone il risultato come dati strutturati sotto run.plan. Non è un semplice dump di testo, ma un oggetto in cui, tra le altre cose, si trova resource_changes.
    2. run.plan.resource_changes è un elenco in cui ogni elemento corrisponde a un’operazione pianificata su una risorsa. Per ogni elemento esistono le change.actions. È un elenco di azioni che Terraform pianifica per questa risorsa. Esempi possibili sono:
      • solo "create" quando una risorsa viene creata ex novo,
      • solo "update" quando una risorsa esistente viene modificata,
      • "create" e "delete" insieme quando Terraform sostituisce, cioè prima elimina e poi ricrea. Questo è il classico replacement.
    3. Le asserzioni sono semplici regole di conteggio su questo elenco:
      • no_destroys_in_plan filtra tutti gli elementi le cui actions contengono la stringa "delete". Se l’insieme non è vuoto, il test fallisce. In questo modo impedisce eliminazioni dure nel plan.
      • cap_creates conta solo le vere nuove creazioni, cioè gli elementi con "create" senza contemporaneo "delete". I replacement non vengono volutamente conteggiati come semplici create. Il messaggio di errore usa format(...) così le virgolette incorporate non causano problemi di sintassi e al contempo vede il valore attuale vs. la soglia.
      • cap_changes limita il numero di aggiornamenti, quindi le modifiche a risorse esistenti.
      • cap_replacements intercetta in modo esplicito i replacement rischiosi, cioè la combinazione di "create" e "delete" sulla stessa risorsa. Questi casi sono spesso i maggiori driver di rischio, perché possono generare downtime o effetti collaterali temporanei.
    4. Le variables { ... } all’inizio impostano le soglie disponibili in tutti i run. Così può adattare la policy per pipeline o ambiente senza modificare la logica del test.
    5. Il test non necessita di costrutti speciali interni al progetto. Lavora solo con il modello di plan standardizzato che Terraform fornisce. Per questo è portabile e (si spera) subito utilizzabile - a meno che non abbia di nuovo inserito qualche errore per sbaglio.

    Se desidera inoltre testare precondition attese che falliscono, può farlo, ma le servono blocchi check nominati nel Suo codice. Esempio:


    # In modules or root
    check "api_limits_reasonable" {
      assert {
        condition     = var.instance_count <= 200
        error_message = "Instance-Batch zu groß."
      }
    }
    
    # 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 ]
    }

    Senza tali check nominati, un test corrispondente puntarebbe nel vuoto. Per il versioning dei moduli o i vincoli di versione il Testing Framework non è adatto, perché il framework guarda ai contenuti del plan, non ai metadati del modulo. Per questo si prestano le Sentinel-Policies o uno script di CI che analizzi le fonti dei moduli e i tag ?ref= o i vincoli version.

    Quando la frittata è fatta

    Supponiamo ora che il Suo state sia già danneggiato e presenti dipendenze errate. Forse allora è arrivato il momento di ricorrere a misure straordinarie. Ecco una misura d’emergenza per i più coraggiosi, ancora una volta solo come link al repository Git: Github - ICTtechnology/module-state-recovery
    Lo script La solleva dal lavoro di dettaglio più faticoso e individua automaticamente i moduli che creano problemi nello state di Terraform. Crea diligentemente un backup, esegue un plan, lo esporta in formato JSON e analizza con precisione quali moduli causano errori. Le risorse interessate vengono visualizzate in anteprima. Se vuole, può anche utilizzare un file di mapping per spostare automaticamente in modo corretto gli indirizzi dello state prima della fase di pulizia. In modalità normale non accade nulla di pericoloso – tutto viene eseguito in modalità Dry-Run. Solo quando Lei avvia consapevolmente con CONFIRM=1 e I_UNDERSTAND=YES, lo script interviene davvero - ma in modo deciso e con l’ascia grande. Si tratta quindi di uno strumento utile per formazione e test, per comprendere e gestire i conflitti tra moduli. Non è però assolutamente destinato all’uso in ambienti di produzione.

     

    Checklist per dipendenze tra moduli robuste

    ✅ Strategia di versioning

    [ ] Semantic Versioning per tutti i moduli di base e di servizio. Chi qui si fa troppo creativo, pagherà il doppio più tardi.
    [ ] Strategia di pinning per ogni livello di modulo (root, service, base). Chi scrive "latest" ha già perso.
    [ ] È presente e mantenuta una Compatibility Matrix per le versioni dei moduli.
    [ ] È integrato un meccanismo di Dependency Lock o script CI per la verifica delle versioni.

    ✅ Governance

    [ ] Il tracciamento delle versioni dei moduli è integrato nella CI/CD.
    [ ] Le Sentinel-Policies per le fonti di moduli consentite e gli intervalli di versione sono stabilite.
    [ ] Le Allow- e Deny-List per le versioni di moduli note come difettose sono configurate.
    [ ] I meccanismi di notifica dei Breaking Change (Release Notes, sistemi di Advisory) sono in atto.
    [ ] L’inventario dei moduli viene aggiornato e verificato regolarmente.

    ✅ Monitoring

    [ ] L’Audit Logging per gli aggiornamenti dei moduli e le modifiche allo state è attivo.
    [ ] I sistemi di allerta precoce per modifiche distruttive (analisi del plan, codici di uscita) sono implementati. Se non esistono, vedrà l’impatto solo in produzione.
    [ ] È attivo un sistema di alerting per modifiche inattese dei moduli o drift delle dipendenze.

    ✅ Recovery

    [ ] La procedura per State Surgery e migrazione degli indirizzi è definita, documentata e comunicata.
    [ ] È disponibile una strategia di rollback per aggiornamenti di moduli falliti.
    [ ] Gli Emergency Response Playbook per problemi critici dei moduli sono creati e comunicati.
    [ ] La formazione del team per il troubleshooting delle dipendenze dei moduli è stabilita. Sì, tutto questo è necessario una volta che la Sua infrastruttura automatizzata raggiunge una certa dimensione. Anche se dovesse farlo contro la resistenza di alcuni engineer che si definiscono Senior.

    Perché nulla è peggiore degli sguardi smarriti quando tutto prende fuoco.