Salta al contenuto principale

Terraforming the Cloud: Provisioning e configurazione di Vault su Hetzner via Terraform e Ansible

Roberto Tazzoli
Autore
Roberto Tazzoli
Appassionato di infrastrutture dichiarative, self-hosting e Kubernetes.
Indice dei contenuti

Terraforming the Cloud: Provisioning e configurazione di Vault su Hetzner via Terraform e Ansible
#

Ci sono sessioni infrastrutturali in cui il valore principale non è il numero di file modificati, ma la conferma che il metodo di lavoro sta funzionando davvero. Questa è una di quelle.

Negli ultimi giorni avevo separato in modo molto netto due fasi del runtime Vault su Hetzner. La prima, hetzner-vault-local-lifecycle (C1), aveva l’obiettivo di dimostrare che il nodo poteva esistere come entità locale coerente: TLS, storage Raft, bootstrap rigoroso, unseal automatico e contratti di identità chiari. La seconda, hetzner-vault-s3-backup-recovery (C2), doveva aggiungere durabilità remota e recovery: snapshot periodici, puntatori coerenti su S3, logica di confronto, riparazione della durabilità remota e, soprattutto, percorso di restore quando il nodo locale non esiste più ma la verità crittografica nel controller e i backup remoti sono ancora sani.

A livello di cronologia, sembrano due lavori separati. A livello reale, però, sono stati un unico percorso. Per circa tre giorni ho lavorato quasi esclusivamente di progettazione: review, raffinamento, chiarimento di contratti, definizione di nomenclature, decision matrix, responsabilità fra Ansible e shell helper, comportamento in caso di stato ambiguo, flusso di bootstrap, ruolo di TazPod, struttura dei receipt, limiti del restore. Quando poi è arrivato il momento di implementare, il lavoro duro era già stato fatto. La parte di esecuzione si è compressa in poche ore e, soprattutto, si è svolta con una fluidità molto diversa da quella tipica dei lavori di questo tipo.

Questo non significa che non ci siano stati problemi. Ce ne sono stati, e alcuni anche istruttivi. Ma il tipo di problema è cambiato. Non mi sono trovato a rimettere in discussione l’architettura nel mezzo della sessione. Mi sono trovato invece davanti a problemi di integrazione, di dettagli operativi, di comportamento reale di tool come Podman, systemd, Tailscale e Vault. È una differenza enorme. Quando il design è solido, anche l’imprevisto smette di essere caos e diventa semplicemente un’anomalia da isolare e correggere.

In questo articolo racconto l’intero passaggio: dal lifecycle locale fino al backup remoto su S3, con la verifica live della rotazione degli snapshot, la prova che lo stato remoto può essere inizializzato e riparato a partire dalla verità locale, il test reale del caso “unchanged snapshot”, i cicli distruttivi di restore e, soprattutto, la chiusura completa della matrice C2. Alla fine del lavoro non è rimasto un ramo “quasi pronto”: la VM è coerente, Vault è attivo, TazPod è coerente, S3 è coerente, il timer di backup è vivo e l’intero set di scenari progettati per C2 è stato eseguito e portato a verde.

Il punto di partenza: una C1 già credibile, non un prototipo fragile
#

Il primo elemento importante da capire è che C2 non è stata costruita sopra una base improvvisata. Il lavoro su C1 aveva già eliminato gran parte dell’entropia iniziale.

Il nodo Vault su Hetzner non era più un semplice container “che parte”. Era già un runtime con una propria identità ben definita. Il nodo host era lushycorp-vm.ts.tazlab.net, il servizio TLS di Vault era lushycorp-api.ts.tazlab.net, i path persistenti erano stabili, la configurazione TLS era chiara, il bootstrap produceva un vault_lineage_id, il receipt locale raccontava l’identità del cluster e il meccanismo di unseal automatico aveva già una forma precisa.

Questa distinzione è fondamentale anche da un punto di vista metodologico. Molti problemi di backup e restore nascono perché si tenta di progettare la recovery remota quando il sistema locale non è ancora definito rigorosamente. In quel caso, il layer remoto eredita ambiguità già presenti nel layer locale e le amplifica. Qui è successo l’opposto: il lavoro fatto su C1 ha ridotto i gradi di libertà. Quando ho iniziato C2, non dovevo più decidere “cosa sia” il nodo Vault. Dovevo solo decidere come estendere in modo rigoroso un’identità già definita.

Qui vale la pena chiarire subito un termine che nel resto dell’articolo uso spesso: lineage. Con vault_lineage_id intendo l’identità stabile di una specifica storia di vita del Vault, cioè la “linea genealogica” di quell’istanza: nasce al primo bootstrap, viene conservata nei receipt locali e negli artifact canonici in TazPod, e serve a distinguere un vero restore della stessa istanza da una nuova inizializzazione che produce invece una nuova identità. In pratica, se ricreo il nodo ma sto davvero riportando in vita lo stesso Vault, la lineage resta la stessa. Se invece faccio un fresh init e nasce un Vault nuovo, nasce anche una lineage nuova.

Per questo la divisione in fasi si è rivelata così utile. foundation, local lifecycle, remote durability non sono stati solo nomi di comodo. Sono stati veri confini diagnostici. Se qualcosa si rompeva nella durabilità remota, sapevo già che non stavo discutendo TLS, Tailscale di base, Podman runtime elementare o bootstrap locale. Questo riduce drasticamente il rumore quando si leggono i log e si devono prendere decisioni rapide.

Il vero valore dei tre giorni di design
#

La parte più interessante di questa sessione, almeno per me, non è stata tanto la scrittura del codice Ansible o dei helper script. È stato constatare in modo molto concreto che i tre giorni di design avevano davvero trasformato la sessione di implementazione.

Il punto non è semplicemente che “si è andati più veloci”. La velocità, in infrastruttura, da sola non dice molto. Si può andare veloci anche nella direzione sbagliata. Il punto è che la sessione si è svolta senza le tipiche rotture di continuità che capitano quando si programma l’architettura mentre si scrive il codice. Non ho dovuto fermarmi a metà per chiedermi se il bucket dovesse contenere l’ultimo snapshot globale o l’ultimo snapshot per lineage. Non ho dovuto ridefinire cosa fosse un restore “lecito”. Non ho dovuto decidere in corsa se l’admin token andasse ricreato sempre o solo in certi casi. Tutte queste scelte erano già state esplicitate.

Questo ha avuto un effetto molto pratico: quando emergeva un problema, il problema era confinato. Se la service unit di backup non passava le variabili giuste, la correzione era locale. Se il path degli snapshot non era montato nel container, la correzione era locale. Se due snapshot logicamente identici avevano hash binari diversi, il problema non diventava improvvisamente una crisi della strategia di backup; diventava un affinamento del contratto di confronto. Questa differenza fra “problema confinato” e “problema sistemico” è il motivo per cui considero questa sessione un successo.

In termini più didattici: la progettazione preventiva non elimina i bug, ma trasforma il tipo di bug che incontri. Riduce il rischio di bug architetturali, cioè quelli che obbligano a cambiare modello mentale a metà lavoro. Quello che resta sono i bug di integrazione, di comportamento reale, di interfaccia fra componenti. Sono comunque fastidiosi, ma molto più trattabili.

C2 in pratica: cosa doveva fare davvero
#

La seconda fase del progetto non doveva limitarsi a “salvare file su S3”. Una formulazione così semplice sarebbe stata pericolosamente incompleta. Il vero obiettivo era introdurre durabilità remota coerente senza confondere il concetto di backup con quello di identità.

Un Vault locale coerente produce una certa verità crittografica: chiavi di unseal, token amministrativi, lineage, stato Raft. Un backup remoto utile non è semplicemente un blob di dati; è un artefatto che deve poter essere riconnesso in modo affidabile a quella stessa identità. Da qui nasce il contratto dei pointer e del metadata. Non basta caricare uno snapshot. Bisogna sapere quale lineage rappresenta, quale slot è attivo, a quale hash corrisponde, e quale sia il candidato corretto da usare in fase di restore.

Per questo ho implementato tre livelli distinti nel bucket:

  1. un global pointer (vault/raft-snapshots/latest.json) che indica il lineage attivo;
  2. un lineage-local pointer (vault/raft-snapshots/<vault_lineage_id>/latest.json) che indica il candidato attuale di restore per quella lineage;
  3. due slot remoti (slot-a e slot-b) che permettono una rotazione semplice e leggibile.

Questa struttura è stata una scelta importante anche per la leggibilità operativa. In fase di incident response, un sistema elegante ma opaco è spesso peggiore di un sistema leggermente più verboso ma trasparente. Qui volevo che un operatore, leggendo gli oggetti in S3 o i log locali, potesse capire quale fosse lo stato attivo senza dover “indovinare” a partire da convenzioni implicite.

L’implementazione del runtime C2
#

L’implementazione materiale si è distribuita su una superficie abbastanza ampia, ma molto ordinata. Ho aggiunto un playbook dedicato (vault-s3-backup-recovery.yml), nuovi file task nell’Ansible role condiviso, due helper shell dedicati per backup e restore, e nuove unità systemd per il timer orario e per il restore esplicito.

Un aspetto importante del design era la divisione di responsabilità tra Ansible e helper shell. Gli helper non dovevano “decidere” il comportamento del sistema. Dovevano eseguire meccanicamente operazioni ristrette: salvare uno snapshot, calcolare hash, leggere o scrivere oggetti S3, eseguire il primitive di restore quando già autorizzato. La visibilità delle scelte — restore sì/no, failure sì/no, lineage selezionata, necessità di recreation dell’admin token — doveva rimanere nei task Ansible. Questo non è solo un vezzo stilistico. È una scelta che migliora auditabilità e debugging. Una macchina a stati che vive in task separati è molto più leggibile di una shell script che ingloba tutto e restituisce un generico codice di uscita.

Sul nodo host sono comparsi anche nuovi punti fissi operativi:

  • /etc/lushycorp-vault/s3.env per le credenziali S3 root-only;
  • /etc/lushycorp-vault/remote-restore.env per il restore request contract;
  • /etc/lushycorp-vault/snapshot-backup-token.txt per il token limitato al backup;
  • /var/log/lushycorp-vault/vault-snapshot-backup.log;
  • /var/log/lushycorp-vault/vault-remote-restore.log.

Questo dettaglio dei path è meno banale di quanto sembri. In sessioni lunghe o distribuite su più giorni, la differenza fra un sistema “osservabile” e uno che costringe a intuire lo stato dai sintomi secondari è enorme. Qui ogni fase importante ha un suo log noto prima dell’avvio. Quando qualcosa non andava, non ero costretto a ricostruire ex post dove potesse essere fallito. Potevo leggerlo direttamente.

I primi problemi: buoni problemi, non problemi architetturali
#

Il primo inciampo reale non ha riguardato Vault, ma il nodo operatore. La prima esecuzione C2 è fallita in fase di validazione Tailscale perché il sistema si aspettava il socket standard di tailscaled, mentre l’operatore locale stava usando una istanza userspace con socket dedicato in /tmp/tailscaled-operator.sock.

Il punto interessante non è tanto il fix — rilanciare create.sh con TAILSCALE_SOCKET=/tmp/tailscaled-operator.sock — quanto il fatto che il problema sia stato immediatamente leggibile e confinato. Il phase log dedicato alla validazione Tailscale mostrava chiaramente il fallimento del path locale. Non c’è stato nessun effetto domino ambiguo su Terraform, Ansible o Vault. Questo è esattamente il tipo di comportamento che ci si aspetta da un’orchestrazione ben separata in fasi.

Subito dopo sono emersi altri due problemi tipici da integrazione:

  • la service unit del backup non passava ancora tutte le variabili operative necessarie (S3_BUCKET, S3_PREFIX, ecc.);
  • il path degli snapshot esisteva sull’host ma non era montato nel container Vault.

Entrambi sono stati risolti senza dover cambiare il modello. Ho corretto i template systemd per passare esplicitamente l’environment richiesto e ho aggiunto il mount della snapshot directory nella service unit del container. Questo è il tipo di lavoro che in una sessione poco preparata rischia di innescare dubbi più ampi (“forse il design del backup è sbagliato”). Qui invece era chiaro fin da subito che si trattava di un difetto locale di wiring.

Il backup iniziale verso S3: prima prova reale della fase C2
#

Una volta corretta la parte di wiring, il primo backup reale ha fatto quello che mi aspettavo da C2: ha trattato il layer remoto come autoritativamente ricostruibile a partire da una verità locale sana.

Questo punto merita una spiegazione. Nel modello che avevo definito, un S3 “vuoto” o “incoerente” non deve bloccare un Vault locale già coerente. Se il nodo locale è sano e TazPod è sano, il layer remoto non è la sorgente primaria di verità: è la durabilità secondaria. Di conseguenza, il backup successivo deve poter inizializzare o riparare il contenuto remoto senza trasformare un problema di backup in un blocco totale del runtime.

Ed è esattamente ciò che è successo. Il primo backup riuscito ha:

  • classificato il remote state;
  • scritto snapshot e metadata su S3;
  • creato il global pointer;
  • creato il lineage-local pointer;
  • impostato il primo slot attivo.

Questa non è una vittoria puramente “meccanica”. È la prova che la distinzione fra verità locale e durabilità remota era stata modellata correttamente. Se il design fosse stato più confuso, il sistema avrebbe potuto tentare restore assurdi, bloccare il nodo per prudenza eccessiva, o scrivere oggetti remoti privi di contesto sufficiente per un futuro rebuild.

Generare uno stato distinguibile: il marker marker-A
#

Per evitare test troppo astratti, ho voluto introdurre uno stato applicativo chiaramente riconoscibile dentro Vault. Ho quindi scritto un marker nel KV store, con identificatore marker-A e scenario baseline-before-matrix.

Perché è importante? Perché i test di backup e restore non devono fermarsi al livello infrastrutturale. Sapere che Vault è “up” o “unsealed” non basta. In un sistema di segreti, la vera domanda è: quali dati contiene esattamente questa istanza? Se dopo un rebuild il sistema torna up ma ha perso o cambiato i dati, il test è fallito anche se systemd è felice.

Questo marker ha avuto due usi molto concreti:

  1. ha reso visibile la differenza tra snapshot di stati diversi;
  2. ha fornito un riferimento da rileggere dopo i cicli distruttivi.

È un piccolo dettaglio, ma rappresenta bene il tipo di approccio che preferisco nelle validazioni: evitare test puramente sintattici e introdurre almeno un segnale funzionale leggibile che permetta di dire “questo è davvero lo stesso Vault logico che mi aspettavo di recuperare”.

La scoperta più interessante: due snapshot uguali logicamente, ma diversi come file
#

Il momento tecnicamente più istruttivo della sessione è arrivato quando ho verificato il comportamento del caso “unchanged snapshot”. Il contratto iniziale prevedeva una logica intuitiva: se l’hash del file snapshot corrente è uguale a quello dell’ultimo snapshot remoto, l’upload può essere saltato.

Sulla carta è ragionevole. Nella pratica, si è rivelato falso.

Ho eseguito un controllo di determinismo salvando due snapshot consecutivi senza modificare lo stato logico di Vault. Mi aspettavo file identici. Invece ho ottenuto:

  • stesso contenuto logico rilevato da vault operator raft snapshot inspect;
  • stesso indice Raft;
  • hash file diversi.

Questa è una differenza importantissima. Significa che il file binario di snapshot incorpora abbastanza variabilità da non poter essere usato come criterio affidabile per dire “lo stato logico è rimasto uguale”. Se avessi lasciato il sistema così, ogni run avrebbe continuato a caricare snapshot nuovi anche in assenza di vere modifiche.

La correzione che ho introdotto è stata semplice nel concetto ma molto importante nel risultato: ho separato integrità del file e equivalenza logica.

  • snapshot_sha256 continua a descrivere il file preciso caricato su S3;
  • snapshot_compare_fingerprint viene calcolato a partire dall’output di vault operator raft snapshot inspect ed è usato per capire se lo stato logico è cambiato davvero.

Dopo questo cambiamento, il test che prima avrebbe prodotto un falso positivo di “changed snapshot” ha finalmente restituito il comportamento corretto: upload-skipped. Per me questo è uno dei punti più riusciti dell’intera sessione, perché è un esempio perfetto di come un test reale possa migliorare il design senza distruggerlo. Il modello generale non era sbagliato. Aveva solo bisogno di un confronto più adatto alla semantica reale di Vault.

Il punto di svolta finale: chiudere davvero il restore T1 + H0 + S1
#

Dopo aver validato il percorso di backup, sono passato alla parte più delicata: il caso T1 + H0 + S1, cioè TazPod coerente, host locale vuoto, S3 coerente. In termini pratici: il nodo viene distrutto, ma il controller possiede ancora il set canonico di bootstrap e S3 possiede un candidate di restore coerente. Questo è il cuore del disaster recovery per la fase C2.

Quando ho scritto la prima versione di questo articolo, quel ramo non era ancora chiuso fino in fondo. I test distruttivi avevano già dimostrato che il restore veniva selezionato correttamente, che il lineage veniva risolto nel modo giusto e che il sistema arrivava molto avanti nella ricostruzione. Restavano però due difetti reali che impedivano di dichiarare la matrice verde.

Il primo era un problema di classificazione del remote state. In alcune condizioni di oggetto mancante su S3, il codice non conservava correttamente la distinzione tra empty e incoherent. Il risultato era sottile ma importante: un lineage-local pointer assente poteva essere trattato nel ramo sbagliato. Il fix è stato piccolo come modifica di shell, ma grande come conseguenza operativa: ho corretto la cattura dell’exit code e reso affidabile la lettura dei 404 di S3 sia nel path di restore sia in quello di backup.

Il secondo era il problema veramente decisivo: dopo il restore il nodo non ricostruiva ancora in modo completamente autonomo il proprio local-unseal path host-side. In pratica, Vault poteva essere riportato fino allo stato corretto, ma le due unseal share locali non venivano sempre reidratate e il servizio di unseal oneshot poteva concludersi troppo presto durante la finestra in cui il container non era ancora nel punto giusto del bootstrap post-restore.

Qui il lavoro utile non è stato “aggiungere retry a caso”, ma rispettare il contratto C1/C2 già definito:

  • il restore C2 ora reidrata esplicitamente sul nodo host unseal-share-1 e unseal-share-2 a partire dal set canonico conservato in TazPod;
  • la logica di vault-local-unseal.sh ora distingue meglio il caso in cui Vault non è ancora inizializzato ma il materiale di unseal esiste già localmente, evitando di dichiarare successo troppo presto;
  • il playbook di convergenza non si limita più a confidare nel solo oneshot systemd: rilancia in modo esplicito l’helper di local unseal dopo il restore, così il check finale di stato avviene davvero dopo la ricostruzione del path di unseal.

Dopo questi fix, il ramo T1 + H0 + S1 è passato fino in fondo. Il nodo viene distrutto, ricreato, Vault viene ripristinato dal candidate corretto su S3, il receipt locale viene aggiornato, le unseal share host-side tornano presenti, il local-unseal riprende correttamente e il Vault finale torna initialized=true e sealed=false senza riconciliazione manuale.

Il segnale più importante, comunque, è rimasto lo stesso: dopo la parte distruttiva e il restore completo, il contenuto logico atteso era ancora lì. Non stavo ottenendo un Vault “vivo ma nuovo”; stavo davvero recuperando l’istanza logica che volevo riportare in linea.

Dalla mezza vittoria alla matrice completa verde
#

La differenza tra una sessione promettente e una sessione chiusa sta tutta qui: a un certo punto smetti di dire “il modello sembra giusto” e inizi a poter dire “la matrice progettata è passata davvero”. È esattamente quello che è successo nel passaggio finale di C2.

Dopo il primo blocco di implementazione e i primi test live, avevo già prove forti su backup, pointer, repair e confronto semantico degli snapshot. Il lavoro finale ha trasformato quelle prove parziali in un set completo di scenari eseguiti uno per uno.

In pratica sono stati chiusi tutti i casi progettati per T7.

Per leggere rapidamente la matrice: T indica lo stato del set canonico in TazPod, H lo stato del nodo host/Vault locale, S lo stato del layer remoto su S3. Il suffisso 0 significa empty, 1 significa coherent, 2 significa incoherent. Quindi T0 = TazPod vuoto, T1 = TazPod coerente, T2 = TazPod incoerente; H0 = host/Vault locale vuoto, H1 = host/Vault locale coerente, H2 = host/Vault locale incoerente; S0 = S3 vuoto, S1 = S3 coerente, S2 = S3 incoerente.

  • T0 + H0 + S0 -> fresh init consentito;
  • T0 + H0 + S1 -> hard fail perché manca l’anchor canonico in TazPod;
  • T1 + H0 + S1 -> restore riuscito durante create.sh;
  • T1 + H0 + S0 -> hard fail, nessun fake restore;
  • T1 + H0 + S2 -> hard fail;
  • T1 + H1 + S0 -> il backup inizializza correttamente il layer remoto;
  • T1 + H1 + S2 -> il backup ripara correttamente il layer remoto da verità locale coerente;
  • unchanged run -> upload-skipped reale;
  • first valid backup into remote-empty lineage -> scrittura su slot-a + lineage-local pointer;
  • changed run on coherent lineage -> switch sullo slot inattivo;
  • pointer mancante con slot ancora presenti -> restore hard-fail e repair successivo tramite backup;
  • metadata mismatch -> hard fail esplicito;
  • TazPod incoerente -> hard fail;
  • host locale incoerente -> hard fail.

Questo passaggio è importante anche concettualmente. Finché una matrice resta parzialmente aperta, il sistema è ancora “promettente”. Quando invece hai coperto anche i casi brutti — pointer assente, metadata corrotti, lineage mismatch, stato locale incoerente — il sistema smette di essere solo convincente in demo e inizia a diventare credibile in esercizio.

Il risultato più bello: gli imprevisti finali hanno confermato il design, non l’hanno demolito
#

Paradossalmente, i problemi emersi nell’ultima parte sono la prova migliore che i giorni di design sono serviti davvero.

Se il modello fosse stato fragile, questi ultimi test avrebbero costretto a rimettere mano alla strategia generale: magari cambiare la struttura dei pointer, cambiare il rapporto fra TazPod e S3, o riscrivere la semantica dei casi empty/incoherent. Invece non è successo. I problemi si sono rivelati esattamente del tipo che speravo di incontrare in una sessione ben preparata: problemi locali, leggibili, confinati.

  • un bug nel capture dell’exit code di shell;
  • un problema preciso nella ricostruzione del materiale host-side dopo restore;
  • un timing troppo ottimistico nel local-unseal post-restore.

Sono problemi veri, ma non sono problemi architetturali. E questa, per me, è la differenza tra una sessione caotica e una sessione ingegnerizzabile.

Lo stato finale lasciato volutamente sano e coerente
#

Alla fine del lavoro non ho lasciato dietro di me una macchina “abbastanza buona per smettere di testare”. Ho riconciliato esplicitamente l’ambiente fino a uno stato finale pulito, coerente e riusabile.

Lo stato conclusivo è questo:

  • VM Hetzner attiva e raggiungibile;
  • lushycorp-vault.service attivo;
  • vault-local-unseal.service attivo;
  • Vault inizializzato e unsealed;
  • TazPod coerente con il set canonico di artifact;
  • S3 coerente con global pointer e lineage-local pointer validi;
  • timer di backup attivo;
  • log principali presenti e consultabili;
  • lineage canonica finale riallineata su d91c4d14-30a6-4518-b162-d1c1a1b9c069.

C’è un dettaglio che considero importante raccontare apertamente: durante i test di fresh init è stata generata anche una nuova lineage temporanea. Sarebbe stato facile considerarla “rumore di laboratorio” e ignorarla. Invece il lavoro serio è proprio quello di non lasciare rumore in giro. Alla fine della sessione quella lineage temporanea non è stata lasciata come stato operativo: il runtime finale è stato riportato in coerenza con la lineage canonica originale, sia su host che in TazPod e S3.

Questa scelta è intenzionale. In un contesto che vuole comportarsi in modo sempre più enterprise, anche la fine della sessione è parte del lavoro. Non basta dimostrare che il test passa. Bisogna lasciare il sistema in una condizione comprensibile e operabile dalla sessione successiva.

Riflessioni post-lab, adesso che C2 è davvero chiusa
#

Se dovessi riassumere questa tappa in una sola frase, oggi la formulerei così: la progettazione ha spostato la difficoltà da “capire cosa costruire” a “chiudere con precisione gli ultimi dettagli reali fino a far passare tutta la matrice”.

È esattamente il tipo di risultato che volevo ottenere con CRISP. Non perché il coding debba diventare banale, ma perché il coding dovrebbe essere l’ultima fase di una catena di decisioni già mature. In questa sessione il risultato si è visto in modo molto concreto: pochi problemi veramente imprevisti, tutti leggibili dai log, quasi tutti confinati, nessun crollo dell’impianto architetturale e nessuna sorpresa davvero distruttiva emersa dal nulla.

La differenza rispetto alla prima bozza di questo articolo è che ora non devo più fermarmi a dire “la recovery non è ancora completamente chiusa”. Posso dire qualcosa di più forte e più utile: il backup remoto è reale, il confronto degli snapshot è stato corretto sulla base del comportamento effettivo di Vault, il restore distruttivo è stato chiuso, i casi di hard-fail sono stati verificati, il runtime resta coerente fra TazPod, host e S3 e la fase C2 può essere considerata conclusa.

Questo, per una piattaforma di segreti su un singolo nodo Hetzner costruita con Podman, systemd, Tailscale, Ansible e S3, è un risultato molto significativo. Non perché sia “perfetto” in senso assoluto, ma perché ha raggiunto quel punto raro in cui design, implementazione, test distruttivi e stato finale operativo raccontano finalmente la stessa storia.

Progettare senza fretta non ha eliminato il lavoro. Lo ha reso proporzionato. E, soprattutto, ha reso l’implementazione abbastanza lineare da far sembrare naturale qualcosa che, senza quei giorni di design, sarebbe probabilmente degenerato in molte più ore di debugging caotico.

Per questa fase, è un ottimo posto dove fermarsi: con un Vault vivo, una durabilità remota credibile, una recovery davvero chiusa, una matrice completa portata a verde e la conferma che il tempo speso a pensare prima del codice continua a essere l’investimento più redditizio dell’intero laboratorio.


Comments