From 8a9da0c31a45aab2781e26d81d2c8a46bdf888c4 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Wed, 30 Sep 2026 02:39:22 -0400 Subject: [PATCH] restauration : la reconstruction remet l'etat de l'incarnation precedente Chaque role proprietaire (AC, bases, annuaire, Nextcloud, rspamd/DKIM, courriel, forge, web) remet son etat au moment ou il le creerait neuf, depuis le dernier instantane anterieur a la naissance de la machine. La sauvegarde refuse de deposer tant qu'un etat d'avant attend. Outil de noeud setops-restaurer ; cibles sauvegarder-maintenant, restauration-etat, restauration-renoncer ; test_restauration. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 48 +++ Makefile | 25 ++ docs/runbooks-construction.yml | 25 +- docs/runbooks-exploitation.md | 43 +++ playbooks/backup/restauration.yml | 64 ++++ roles/client_backup/README.md | 4 +- roles/client_backup/defaults/main.yml | 21 ++ roles/client_backup/tasks/acces.yml | 68 ++++ roles/client_backup/tasks/acter.yml | 31 ++ roles/client_backup/tasks/main.yml | 58 +--- roles/client_backup/tasks/restaurer.yml | 98 ++++++ roles/client_backup/templates/restaurer.sh.j2 | 315 ++++++++++++++++++ .../client_backup/templates/sauvegarder.sh.j2 | 7 + roles/serveur_dovecot/tasks/main.yml | 35 ++ roles/serveur_forgejo/tasks/main.yml | 48 +++ roles/serveur_nextcloud/tasks/main.yml | 36 ++ roles/serveur_nextcloud/tasks/restaurer.yml | 129 +++++++ roles/serveur_openldap/tasks/main.yml | 66 ++++ roles/serveur_postgresql/defaults/main.yml | 22 ++ roles/serveur_postgresql/tasks/main.yml | 62 ++++ roles/serveur_rspamd/tasks/main.yml | 49 +++ roles/serveur_step_ca/tasks/main.yml | 66 ++++ roles/serveur_web_dorsal/tasks/main.yml | 34 ++ roles/serveur_web_frontal/tasks/main.yml | 34 ++ scripts/tests/test_restauration.py | 213 ++++++++++++ 25 files changed, 1544 insertions(+), 57 deletions(-) create mode 100644 playbooks/backup/restauration.yml create mode 100644 roles/client_backup/tasks/acces.yml create mode 100644 roles/client_backup/tasks/acter.yml create mode 100644 roles/client_backup/tasks/restaurer.yml create mode 100644 roles/client_backup/templates/restaurer.sh.j2 create mode 100644 roles/serveur_nextcloud/tasks/restaurer.yml create mode 100644 scripts/tests/test_restauration.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c9e8e9..e6d7717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,53 @@ # CHANGELOG — Set-OPS +## 2026-09-30 (47) — La reconstruction remet l'état : chaque rôle propriétaire restaure le sien + +**Le défaut** (46) : une reconstruction repartait d'un état neuf. Les instantanés se +restauraient pour PROUVER qu'ils s'ouvrent, jamais pour être remis en service. + +**Le principe retenu** : pas d'étape à part dans la chaîne — chaque rôle qui POSSÈDE un +état le remet **au moment où il le créerait neuf**, et l'ordre vient des couches. La chaîne +des runners (`_amorcer-socle` → `deployer-tout`) et `make reconstruire` n'ont pas changé. + +| Rôle | Point de remise | +|---|---| +| `serveur_step_ca` | avant `step ca init` ; refus si la voûte n'ouvre plus les clés restaurées | +| `serveur_postgresql` | bases juste créées, chacune rejouée en UNE transaction | +| `serveur_openldap` | fin de rôle (le LDIF exige `ppolicy`), puis comptes de service réalignés sur la voûte | +| `serveur_nextcloud` | fin de rôle : base + fichiers + config ensemble, `occ upgrade` | +| `serveur_rspamd` | avant la génération DKIM — sinon chaque reconstruction changeait la clé publiée | +| `serveur_dovecot`, `serveur_forgejo`, `serveur_web_*` | racine encore vide | + +**Nextcloud se remet en fin de rôle, pas avant l'installation** : le code de `user_oidc` et +`richdocuments` n'est pas sauvegardé ; restaurée avant, la base les dirait installés et +`occ app:install` refuserait. `serveur_postgresql` exclut donc sa base (et celle +d'Icinga, dont l'historique est lié à un environnement non sauvegardé). + +**Quelle incarnation** : le dernier instantané pris AVANT la naissance de la machine — la +date de sa clé d'hôte SSH (`machine-id`, lui, vient du gabarit : tous les nœuds portent le +2026-09-01). Un état en place n'est jamais écrasé d'office ; ce qui est remplacé est mis de +côté (`/var/backups/setops-avant-restauration/`) ; chaque jeu laisse un marqueur. + +**La sauvegarde refuse de déposer tant qu'un état d'avant attend** (code 3). Sans cette +garde, `restic forget --keep-daily 7` aurait gardé l'instantané de l'état NEUF et chassé +celui d'avant dès qu'ils tombaient le même jour — ce matin, ils étaient à cheval sur minuit +par hasard. + +**Nouveaux gestes** : `make sauvegarder-maintenant` (juste avant `raser`, inscrit au +runbook `flotte-refaire`), `make restauration-etat`, `make restauration-renoncer`. Outil de +nœud utilisable sans Ansible : `setops-restaurer` (avec des répétitions qui ne touchent à +rien : `annuaire --essai`, `base --vers`, `fichiers --vers`). + +**Garde** : `scripts/tests/test_restauration.py` — chaque détenteur d'état du catalogue doit +avoir son point de remise (une liste qui suit une autre prend du retard), et l'outil, rendu +depuis son gabarit avec des doublures de `restic`/`psql`, doit choisir l'incarnation d'avant, +refuser d'écraser, bloquer puis libérer la sauvegarde, et couper la section d'une base avant +le `DROP DATABASE postgres;`. Le test a trouvé un défaut avant tout déploiement : une +apostrophe dans `${c:-…}`, que bash lit comme un guillemet ouvrant. + +**Pas encore éprouvé par une reconstruction réelle** : validé par `ansible-lint` (0 remarque), +`--syntax-check`, `make test`. L'épreuve est la prochaine reconstruction. + ## 2026-09-30 (46) — Technolibre : l'état d'avant la reconstruction remis en place, sans reconstruire **Le constat** : après (45), le mot de passe de `sysadmin` était revenu à celui de l'amorçage. diff --git a/Makefile b/Makefile index 015344c..d67a884 100644 --- a/Makefile +++ b/Makefile @@ -321,6 +321,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire) python3 scripts/tests/test_expositions_sans_port.py python3 scripts/tests/test_proxmox_fw_externe.py python3 scripts/tests/test_sonde_tcp.py + python3 scripts/tests/test_restauration.py # LA SONDE QUI RAPPORTE CE QUI DISTINGUE (2026-08-28). Trois faux diagnostics en une # journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine @@ -900,6 +901,30 @@ flux-verifier: ## Verifie que le registre des flux correspond aux meta/flux.yml valider: ansible-runtime ## Passe la recette de validation sur la flotte ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/valider.yml +.PHONY: restauration-etat restauration-renoncer sauvegarder-maintenant +sauvegarder-maintenant: ansible-runtime ## Depose l'etat de chaque noeud TOUT DE SUITE (avant de raser) — HOTE= optionnel + @set -e; $(VAULT_UNE_FOIS) \ + ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/backup/restauration.yml \ + -e restauration_action=sauvegarder $(if $(HOTE),--limit "$(HOTE)") + +# LA RESTAURATION DANS LA RECONSTRUCTION (2026-09-30) — la remise elle-meme n'a pas de +# cible : chaque role proprietaire la fait au deploiement. Ces deux cibles-ci servent a +# VOIR ce qui attend, et a ECARTER un etat qu'on ne veut pas remettre. +restauration-etat: ansible-runtime ## Ce qui attend dans le depot, et ce que chaque jeu est devenu — HOTE= optionnel + @set -e; $(VAULT_UNE_FOIS) \ + ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/backup/restauration.yml \ + $(if $(HOTE),--limit "$(HOTE)") + +restauration-renoncer: ansible-runtime ## Ecarte l'etat anterieur d'un jeu, sans le remettre — HOTE= JEU= CONFIRMER=true + @set -e; \ + if [[ "$(CONFIRMER)" != "true" || -z "$(HOTE)" || -z "$(JEU)" ]]; then \ + printf '%s\n' 'Refus: ECARTER un etat anterieur (il sortira de la retention). HOTE=, JEU= et CONFIRMER=true requis.'; \ + exit 2; \ + fi; \ + $(VAULT_UNE_FOIS) \ + ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/backup/restauration.yml \ + --limit "$(HOTE)" -e restauration_action=renoncer -e restauration_jeu="$(JEU)" + .PHONY: schema schema: ## Regenere docs/audit/schema-plan.json depuis les registres et les validateurs python3 scripts/schema_plan.py diff --git a/docs/runbooks-construction.yml b/docs/runbooks-construction.yml index 6933d3b..ae9d695 100644 --- a/docs/runbooks-construction.yml +++ b/docs/runbooks-construction.yml @@ -84,6 +84,8 @@ variables: DANS: invite: "Jours avant le second temps" facultatif: true + JEU: + invite: "Le jeu d'etat (openldap, postgresql, nextcloud... — voir restauration-etat)" VMID: invite: "Identifiant Proxmox de la VM" DIALECTE: @@ -458,6 +460,12 @@ runbooks: La preuve la plus dure du moteur : detruire un ecosysteme et le refaire depuis le code seul. A ne lancer que sur un chantier — la prod vit ailleurs. etapes: + - cible: sauvegarder-maintenant + nature: ecriture + pourquoi: >- + Deposer l'etat de chaque noeud JUSTE AVANT de raser. La reconstruction remet le + dernier instantane anterieur a la naissance des machines : sans ce depot, c'est + celui de la nuit, et la journee est perdue. - cible: raser nature: destructif portee: poste @@ -472,8 +480,21 @@ runbooks: duree: "long" fixes: {CONFIRMER: "true"} pourquoi: >- - Refaire tout depuis zero : les VM, puis le deploiement complet. Si le code ne - suffit pas, c'est ici qu'on l'apprend. + Refaire tout depuis zero : les VM, puis le deploiement complet — ou chaque role + proprietaire REMET l'etat de l'incarnation precedente (AC, annuaire, bases, + Nextcloud, courriel, DKIM). Si le code ne suffit pas, c'est ici qu'on l'apprend. + - cible: restauration-etat + nature: mesure + pourquoi: >- + Ce que chaque jeu est devenu : restaure (et depuis quel instantane), neuf, en + place. Un jeu EN ATTENTE bloque la sauvegarde de son noeud — c'est voulu. + - cible: restauration-renoncer + nature: destructif + variables: [HOTE, JEU] + fixes: {CONFIRMER: "true"} + pourquoi: >- + Ecarter l'etat d'avant d'un jeu, sans le remettre. La sauvegarde reprend, et + l'ancien etat sortira de la retention : une decision, pas une reparation. - cible: valider nature: mesure pourquoi: "Une reconstruction sans recette n'a rien prouve." diff --git a/docs/runbooks-exploitation.md b/docs/runbooks-exploitation.md index 7d602ff..98c126d 100644 --- a/docs/runbooks-exploitation.md +++ b/docs/runbooks-exploitation.md @@ -112,6 +112,49 @@ Note : ne s'applique qu'aux Forgejo **gérées par Set-OPS**. ## 5. Restaurer — et d'abord : prouver qu'on peut +### 5.0 La reconstruction remet l'état (depuis le 2026-09-30) + +Jusqu'au 2026-09-30, **une reconstruction repartait d'un état neuf** : nouvelle racine +d'AC, annuaire vierge (`sysadmin` au mot de passe d'amorçage), bases, Nextcloud et courriel +vides. Les instantanés se restauraient pour *prouver* qu'ils s'ouvrent, jamais pour être +remis en service. + +Désormais **chaque rôle propriétaire remet l'état de l'incarnation précédente**, au moment +où il le créerait neuf — la bonne séquence vient des couches de déploiement : + +| Rôle | Quand | Condition « vierge » | +|---|---|---| +| `serveur_step_ca` | avant `step ca init` | pas de `ca.json` — et la voûte doit ouvrir les clés restaurées | +| `serveur_postgresql` | juste après la création des bases | base sans table (hors `nextcloud`, `icinga`) | +| `serveur_openldap` | en fin de rôle (schéma et `ppolicy` chargés) | aucune entrée hors la racine | +| `serveur_dovecot` | après le répertoire des boîtes | répertoire vide | +| `serveur_rspamd` | avant la génération DKIM | pas de clé DKIM | +| `serveur_forgejo` | avant l'arborescence de données | répertoire vide | +| `serveur_nextcloud` | **en fin de rôle** : base + fichiers + config ensemble, puis `occ upgrade` | pas de `config.php` au départ, base vide | +| `serveur_web_frontal` / `_dorsal` | après leur racine | racine vide | + +**L'instantané remis** est le dernier pris **avant la naissance de la machine** (date de +sa clé d'hôte SSH). Un état en place n'est jamais écrasé d'office. Chaque jeu laisse un +marqueur dans `/etc/setops/restauration/` ; ce qui a été remplacé est mis de côté dans +`/var/backups/setops-avant-restauration/`. + +**La sauvegarde refuse de déposer** (code 3) tant qu'un état d'avant n'a été ni remis ni +écarté : sinon `restic forget --keep-daily` chasserait l'instantané d'avant au profit de +l'état neuf du même jour. + +Les gestes : + +``` +make sauvegarder-maintenant # JUSTE AVANT de raser : sinon on perd la journée +make restauration-etat [HOTE=...] # ce que chaque jeu est devenu +make restauration-renoncer HOTE=.. JEU=.. CONFIRMER=true # écarter un état, sans le remettre +-e client_backup_restauration_instantane= # imposer un instantané, par hôte +``` + +Sur un nœud, sans Ansible : `setops-restaurer etat`, et les répétitions qui ne touchent à +rien — `setops-restaurer annuaire --essai `, `base --vers epreuve`, +`fichiers --vers /var/tmp/epreuve `. + > Éprouvé le 2026-08-12 sur Chezlepro. `make valider` rejoue la partie automatisable ; > la restauration d'une base reste manuelle, et porte un piège décrit plus bas. diff --git a/playbooks/backup/restauration.yml b/playbooks/backup/restauration.yml new file mode 100644 index 0000000..af7bb6e --- /dev/null +++ b/playbooks/backup/restauration.yml @@ -0,0 +1,64 @@ +--- +# L'ETAT D'UNE INCARNATION PRECEDENTE — voir roles/client_backup/templates/restaurer.sh.j2 +# +# make restauration-etat [HOTE=...] +# ce qui attend dans le depot, et ce que chaque jeu est devenu (lecture seule) ; +# make sauvegarder-maintenant [HOTE=...] +# deposer TOUT DE SUITE, et attendre que ce soit fait. A faire juste avant `raser` : +# sinon la reconstruction remet l'etat de la derniere nuit, et perd la journee ; +# make restauration-renoncer HOTE= JEU= CONFIRMER=true +# ECARTER un etat anterieur sans le remettre. La sauvegarde du noeud, qui refusait +# de deposer pour ne pas le chasser de la retention, reprend — et l'etat d'avant +# finira par sortir de la retention. C'est une decision, pas une reparation. +# +# La REMISE, elle, n'a pas de cible : elle est faite par le role proprietaire de l'etat, +# au deploiement, au moment ou il le creerait neuf. +- name: L'etat d'une incarnation precedente + hosts: client_backup + become: true + gather_facts: false + vars: + restauration_action: etat + restauration_jeu: "" + tasks: + - name: Refuser un renoncement sans jeu nomme + ansible.builtin.assert: + that: + - restauration_jeu | length > 0 + fail_msg: "JEU= requis (voir `make restauration-etat` pour les noms)." + when: restauration_action == 'renoncer' + + - name: Lire ce qui attend, et ce qui a ete tranche + ansible.builtin.command: + argv: [/usr/local/sbin/setops-restaurer, etat] + register: restauration_lue + changed_when: false + failed_when: false + when: restauration_action == 'etat' + + - name: Etat + ansible.builtin.debug: + msg: "{{ (restauration_lue.stdout_lines | default([])) + (restauration_lue.stderr_lines | default([])) }}" + when: restauration_action == 'etat' + + # L'unite est `oneshot` : `systemctl start` rend la main quand le depot est fait, et + # echoue s'il a echoue — y compris quand la garde refuse (etat d'avant non remis). + - name: Ce noeud a-t-il quelque chose a deposer ? + ansible.builtin.stat: + path: /etc/systemd/system/setops-sauvegarde.service + register: restauration_unite + when: restauration_action == 'sauvegarder' + + - name: Deposer maintenant + ansible.builtin.systemd: + name: setops-sauvegarde.service + state: started + when: + - restauration_action == 'sauvegarder' + - restauration_unite.stat.exists + + - name: Ecarter l'etat anterieur de ce jeu + ansible.builtin.command: + argv: [/usr/local/sbin/setops-restaurer, acter, "{{ restauration_jeu }}", abandonne] + changed_when: true + when: restauration_action == 'renoncer' diff --git a/roles/client_backup/README.md b/roles/client_backup/README.md index 725e46d..6418d95 100644 --- a/roles/client_backup/README.md +++ b/roles/client_backup/README.md @@ -55,7 +55,9 @@ Sans eux, le rôle refuse de s'exécuter (assertion). vérifier que chaque nœud porteur d'état déclare ses jobs. - Un dépôt neuf est **vide jusqu'à la première exécution** : après une reconstruction from-zero, déclencher une première sauvegarde plutôt que d'attendre 02:30. -- Restauration : `restic restore` avec le même mot de passe (cf. `docs/runbooks-exploitation.md`). +- Restauration : **faite par la reconstruction elle-même** depuis le 2026-09-30 — chaque rôle + propriétaire inclut `tasks/restaurer.yml` et remet l'état de l'incarnation précédente ; + l'outil de nœud est `setops-restaurer` (cf. `docs/runbooks-exploitation.md` §5.0). ## Prérequis - `serveur_backup` déployé, et sa `serveur_backup_pubkey` correspondant à la clé privée de diff --git a/roles/client_backup/defaults/main.yml b/roles/client_backup/defaults/main.yml index b9313c0..60b7204 100644 --- a/roles/client_backup/defaults/main.yml +++ b/roles/client_backup/defaults/main.yml @@ -3,6 +3,8 @@ # Chaque nœud déclare ses "jobs" ; restic chiffre côté client + applique la rétention. client_backup_paquets: - restic + # `setops-restaurer` remet les repertoires par `rsync --delete` (absent du gabarit). + - rsync # Cible (nœud serveur_backup) + transport SSH. client_backup_cible: "backup-01.{{ domaine_interne }}" @@ -128,3 +130,22 @@ client_backup_restauration_part: "10%" # Huit jours : une semaine entre deux controles, plus une journee de marge. Au-dela sans # nouvelle, Icinga passe le service au rouge (voir `serveur_icinga_fraicheur_restauration`). client_backup_ttl_restauration: 691200 + +# --- LA RESTAURATION DANS LA RECONSTRUCTION (2026-09-30) ---------------------------- +# +# Jusqu'ici une reconstruction repartait d'un etat NEUF : les instantanes se restauraient +# pour PROUVER qu'ils s'ouvrent, jamais pour etre remis en service. Desormais chaque role +# proprietaire d'un etat inclut `tasks/restaurer.yml` au moment ou il le creerait neuf, et +# remet celui de l'incarnation precedente s'il existe. Voir `templates/restaurer.sh.j2`. +# +# `false` : ne jamais restaurer (chaque jeu est alors acte « desactive », et la +# sauvegarde ne s'en trouve pas bloquee). +client_backup_restauration_active: true +# Imposer un instantane (son `short_id`) au lieu du dernier anterieur a la naissance. +# Par hote, et le temps d'une passe : `-e client_backup_restauration_instantane=abcd1234`. +client_backup_restauration_instantane: "" +# Ou chaque jeu laisse la trace de ce qui lui est arrive (restaure, neuf, en_place...). +client_backup_restauration_marques: "/etc/setops/restauration" +# Ce qui etait en place avant d'etre ecrase. Hors des chemins sauvegardes : on ne veut +# pas deposer chez l'hebergeur l'etat qu'on vient justement de remplacer. +client_backup_restauration_mise_de_cote: "/var/backups/setops-avant-restauration" diff --git a/roles/client_backup/tasks/acces.yml b/roles/client_backup/tasks/acces.yml new file mode 100644 index 0000000..0ac511c --- /dev/null +++ b/roles/client_backup/tasks/acces.yml @@ -0,0 +1,68 @@ +--- +# ACCES AU DEPOT DE CE NOEUD — partage par la sauvegarde (`main.yml`) et par la +# restauration (`restaurer.yml`, incluse par les roles proprietaires de l'etat). +# +# Une seule copie de ces taches : deux facons de poser une cle finiraient par diverger, +# et on ne le decouvrirait que le jour ou l'on restaure. +- name: Installer restic + ansible.builtin.apt: + name: "{{ client_backup_paquets }}" + state: present + update_cache: true + +- name: Créer le répertoire des secrets Set-OPS + ansible.builtin.file: + path: /etc/setops + state: directory + owner: root + group: root + mode: "0700" + +- name: Déposer la clé SSH privée de sauvegarde + ansible.builtin.copy: + content: "{{ client_backup_ssh_privkey }}" + dest: "{{ client_backup_ssh_key }}" + owner: root + group: root + mode: "0600" + no_log: true + +- name: Déposer le mot de passe restic + ansible.builtin.copy: + content: "{{ client_backup_password }}\n" + dest: /etc/setops/restic.pass + owner: root + group: root + mode: "0600" + no_log: true + +- name: Assurer /root/.ssh + ansible.builtin.file: + path: /root/.ssh + state: directory + owner: root + group: root + mode: "0700" + +- name: Configurer l'accès SSH à la cible de sauvegarde + ansible.builtin.blockinfile: + path: /root/.ssh/config + create: true + owner: root + group: root + mode: "0600" + marker: "# {mark} SETOPS BACKUP {{ client_backup_cible }}" + block: | + Host {{ client_backup_cible }} + User {{ client_backup_utilisateur_distant }} + IdentityFile {{ client_backup_ssh_key }} + IdentitiesOnly yes + StrictHostKeyChecking accept-new + +- name: Déployer l'outil de restauration (setops-restaurer) + ansible.builtin.template: + src: restaurer.sh.j2 + dest: /usr/local/sbin/setops-restaurer + owner: root + group: root + mode: "0700" diff --git a/roles/client_backup/tasks/acter.yml b/roles/client_backup/tasks/acter.yml new file mode 100644 index 0000000..26770a4 --- /dev/null +++ b/roles/client_backup/tasks/acter.yml @@ -0,0 +1,31 @@ +--- +# POSER LE MARQUEUR D'UN JEU — APRES le geste, jamais avant : ecrit d'abord, une +# restauration ratee laisserait le noeud convaincu d'avoir remis un etat qu'il n'a pas, +# et la sauvegarde recommencerait a deposer par-dessus. +# +# entree : client_backup_acter_etat (restaure, neuf, en_place, desactive, abandonne) +# client_backup_restauration (le jeu, et l'instantane s'il y en a un) +- name: Restauration — repertoire des marqueurs + ansible.builtin.file: + path: "{{ client_backup_restauration_marques }}" + state: directory + owner: root + group: root + mode: "0700" + when: not ansible_check_mode + +- name: Restauration — acter le jeu + ansible.builtin.copy: + dest: "{{ client_backup_restauration_marques }}/{{ client_backup_restauration.jeu }}" + content: | + etat={{ client_backup_acter_etat }} + instantane={{ client_backup_restauration.instantane | default('') }} + date={{ now(utc=true).isoformat() }} + owner: root + group: root + mode: "0600" + when: not ansible_check_mode + +- name: Restauration — retenir l'etat acte + ansible.builtin.set_fact: + client_backup_restauration: "{{ client_backup_restauration | combine({'etat': client_backup_acter_etat}) }}" diff --git a/roles/client_backup/tasks/main.yml b/roles/client_backup/tasks/main.yml index a6351d9..76cdaff 100644 --- a/roles/client_backup/tasks/main.yml +++ b/roles/client_backup/tasks/main.yml @@ -34,60 +34,10 @@ + (client_backup_jobs | map(attribute='nom') | join(', ')), 'aucun etat non regenerable — aucune sauvegarde installee') }} -- name: Installer restic - ansible.builtin.apt: - name: "{{ client_backup_paquets }}" - state: present - update_cache: true - -- name: Créer le répertoire des secrets Set-OPS - ansible.builtin.file: - path: /etc/setops - state: directory - owner: root - group: root - mode: "0700" - -- name: Déposer la clé SSH privée de sauvegarde - ansible.builtin.copy: - content: "{{ client_backup_ssh_privkey }}" - dest: "{{ client_backup_ssh_key }}" - owner: root - group: root - mode: "0600" - no_log: true - -- name: Déposer le mot de passe restic - ansible.builtin.copy: - content: "{{ client_backup_password }}\n" - dest: /etc/setops/restic.pass - owner: root - group: root - mode: "0600" - no_log: true - -- name: Assurer /root/.ssh - ansible.builtin.file: - path: /root/.ssh - state: directory - owner: root - group: root - mode: "0700" - -- name: Configurer l'accès SSH à la cible de sauvegarde - ansible.builtin.blockinfile: - path: /root/.ssh/config - create: true - owner: root - group: root - mode: "0600" - marker: "# {mark} SETOPS BACKUP {{ client_backup_cible }}" - block: | - Host {{ client_backup_cible }} - User {{ client_backup_utilisateur_distant }} - IdentityFile {{ client_backup_ssh_key }} - IdentitiesOnly yes - StrictHostKeyChecking accept-new +# L'ACCES AU DEPOT vit a part : les roles proprietaires l'incluent aussi, pour RESTAURER +# avant meme que ce role-ci ait tourne (l'AC se restaure dans la couche `pki_racine`). +- name: Accès au dépôt (restic, clé, mot de passe, SSH) et outil de restauration + ansible.builtin.include_tasks: acces.yml - name: Assurer le répertoire de préparation des dumps ansible.builtin.file: diff --git a/roles/client_backup/tasks/restaurer.yml b/roles/client_backup/tasks/restaurer.yml new file mode 100644 index 0000000..338e038 --- /dev/null +++ b/roles/client_backup/tasks/restaurer.yml @@ -0,0 +1,98 @@ +--- +# L'ETAT D'UNE INCARNATION PRECEDENTE ATTEND-IL CE JEU ? +# +# Inclus par le role PROPRIETAIRE de l'etat, au moment precis ou il le creerait neuf : +# l'AC avant `step ca init`, les bases juste creees, l'annuaire une fois son schema pose... +# C'est lui qui sait quand son etat est « vierge » ; ce fichier sait ou est le depot. +# +# entree : client_backup_restaurer_jeu — groupe du catalogue (ex. serveur_step_ca) +# client_backup_restaurer_vierge — le role n'a encore RIEN de cet etat +# sortie : client_backup_restauration — {jeu, etat, instantane, chemins} +# +# etat : `a_restaurer` (au role de le remettre, puis d'inclure `acter.yml`), `acte` +# (deja tranche a une passe precedente), `en_place` (un etat existe : on ne l'ecrase +# jamais d'office), `neuf` (rien d'anterieur), `sans_objet`, `simulation`. +- name: Restauration — le jeu et ce qu'il emporte + ansible.builtin.set_fact: + client_backup_restauration: + jeu: "{{ client_backup_catalogue[client_backup_restaurer_jeu].nom }}" + chemins: "{{ client_backup_catalogue[client_backup_restaurer_jeu].chemins }}" + etat: >- + {{ 'simulation' if ansible_check_mode + else ('sans_objet' if ('client_backup' not in group_names) + else ('desactive' if not (client_backup_restauration_active | bool) + else 'a_decider')) }} + instantane: "" + +- name: Restauration — trancher pour ce jeu + when: client_backup_restauration.etat != 'sans_objet' and client_backup_restauration.etat != 'simulation' + block: + - name: Restauration — ce jeu a-t-il deja ete tranche ? + ansible.builtin.slurp: + src: "{{ client_backup_restauration_marques }}/{{ client_backup_restauration.jeu }}" + register: client_backup_marque_lue + failed_when: false + + - name: Restauration — deja tranche a une passe precedente + ansible.builtin.set_fact: + client_backup_restauration: >- + {{ client_backup_restauration | combine({'etat': 'acte', + 'instantane': ((client_backup_marque_lue.content | b64decode).splitlines() + | select('match', '^instantane=') | first + | default('instantane=')).split('=', 1)[1]}) }} + when: client_backup_marque_lue.content is defined + + - name: "Restauration — desactivee, ou un etat existe deja, l'acter tel quel" + ansible.builtin.include_tasks: acter.yml + vars: + client_backup_acter_etat: "{{ 'desactive' if client_backup_restauration.etat == 'desactive' else 'en_place' }}" + when: + - client_backup_marque_lue.content is not defined + - client_backup_restauration.etat == 'desactive' or not (client_backup_restaurer_vierge | bool) + + # LA CONDITION D'UN BLOC SE REEVALUE A CHAQUE TACHE. Ecrite sur `etat`, elle se + # fermerait des que le choix le change — et « rien d'anterieur » ne serait jamais acte. + # On la fige donc avant d'entrer. + - name: Restauration — faut-il interroger le depot ? + ansible.builtin.set_fact: + client_backup_interroger: >- + {{ client_backup_marque_lue.content is not defined + and client_backup_restauration.etat == 'a_decider' + and (client_backup_restaurer_vierge | bool) }} + + - name: Restauration — interroger le depot + when: client_backup_interroger | bool + block: + - name: Restauration — acces au depot (le role client_backup n'a peut-etre pas encore tourne) + ansible.builtin.include_tasks: acces.yml + + # Code 2 = depot ILLISIBLE : on echoue. Conclure « rien a restaurer » faute de + # joindre le depot ferait naitre neuf ce qui attend chez l'hebergeur. + - name: Restauration — l'instantane de l'incarnation precedente + ansible.builtin.command: + argv: >- + {{ ['/usr/local/sbin/setops-restaurer', 'choisir'] + + (['--instantane', client_backup_restauration_instantane] + if client_backup_restauration_instantane | length > 0 else []) }} + register: client_backup_choix + changed_when: false + + - name: Restauration — retenir le choix + ansible.builtin.set_fact: + client_backup_restauration: >- + {{ client_backup_restauration | combine({ + 'etat': ('a_restaurer' if (client_backup_choix.stdout | from_json).instantane else 'neuf'), + 'instantane': (client_backup_choix.stdout | from_json).instantane}) }} + + - name: "Restauration — rien d'anterieur, naissance" + ansible.builtin.include_tasks: acter.yml + vars: + client_backup_acter_etat: neuf + when: client_backup_restauration.etat == 'neuf' + +- name: Restauration — verdict + ansible.builtin.debug: + msg: >- + {{ client_backup_restauration.jeu }} : {{ client_backup_restauration.etat }}{{ + (' — instantane ' + client_backup_restauration.instantane) + if client_backup_restauration.instantane | length > 0 else '' }} diff --git a/roles/client_backup/templates/restaurer.sh.j2 b/roles/client_backup/templates/restaurer.sh.j2 new file mode 100644 index 0000000..bb3bc13 --- /dev/null +++ b/roles/client_backup/templates/restaurer.sh.j2 @@ -0,0 +1,315 @@ +#!/bin/bash +# GENERE par Set-OPS (role client_backup). Ne pas editer a la main. +# +# setops-restaurer — REMETTRE EN SERVICE l'etat d'une incarnation precedente de CE noeud. +# +# POURQUOI IL EXISTE (2026-09-30). Technolibre a ete rasee et reconstruite par ses +# runners : 0 echec, `make valider` vert, « restauration verifiee ». Et le mot de passe de +# `sysadmin` etait revenu a celui de l'amorcage. RIEN n'etait revenu : ni l'annuaire, ni +# la racine de l'AC, ni les bases, ni les fichiers de Nextcloud. « Restauration verifiee » +# voulait dire : restauree dans un repertoire temporaire, lue, jetee. Chaque +# reconstruction de Chezlepro etait repartie a vide de la meme facon. +# +# QUELLE INCARNATION. La cle d'hote SSH nait au clonage (`machine-id`, lui, vient du +# gabarit : tous les noeuds portent la meme date). L'instantane a remettre est donc le +# DERNIER PRIS AVANT LA NAISSANCE de cette cle — celui de la machine qu'on a rasee. +# `--instantane ID` impose un autre choix. +# +# LE MARQUEUR. Un jeu restaure, abandonne, ou trouve deja en place laisse une trace dans +# {{ client_backup_restauration_marques }}/. La sauvegarde s'y fie (`garde`) : tant +# qu'un etat anterieur n'a ete ni remis ni ecarte, ce noeud REFUSE de deposer — sans quoi +# `restic forget --keep-daily` chasserait l'instantane d'avant au profit de l'etat neuf du +# meme jour. +# +# RIEN N'EST ECRASE SANS ETRE MIS DE COTE, dans {{ client_backup_restauration_mise_de_cote }}. +# +# Sous-commandes : +# etat ce qui serait restaure, et les marqueurs +# choisir [--instantane ID] (pour Ansible) JSON de l'instantane candidat +# fichiers [opts] CHEMIN... remettre des repertoires (rsync --delete) +# --proprietaire U:G --remplacer --vers DOSSIER (repetition, rien n'est touche) +# annuaire [opts] BASE_DN remplacer la base LDAP (slapadd) +# --essai (slapadd -u a blanc, rien n'est touche) +# base [opts] NOM rejouer une base depuis le pg_dumpall +# --remplacer --vers AUTRE_NOM (repetition dans une base jetable) +# acter JEU ETAT [INSTANTANE] poser le marqueur (restaure, neuf, en_place, abandonne) +# garde (pour la sauvegarde) refuser si un etat attend +# Options communes : --instantane ID. +set -euo pipefail + +export RESTIC_REPOSITORY="{{ client_backup_repo }}" +export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass" +MOI="{{ inventory_hostname }}" +MARQUES="{{ client_backup_restauration_marques }}" +MISE_DE_COTE="{{ client_backup_restauration_mise_de_cote }}" +CLE_HOTE="/etc/ssh/ssh_host_ed25519_key.pub" +LDIF="{{ client_backup_staging }}/openldap/annuaire.ldif" +DUMP_PG="{{ client_backup_staging }}/postgresql/toutes-bases.sql" +JEUX=({% for j in client_backup_jobs %} "{{ j.nom }}"{% endfor %} ) + +dire() { printf '%s\n' "$*" >&2; } +mourir() { dire "setops-restaurer: $*"; exit 1; } + +naissance() { + local b + b=$(stat -c %W "${CLE_HOTE}") + if [[ "${b}" -le 0 ]]; then b=$(stat -c %Y "${CLE_HOTE}"); fi + printf '%s\n' "${b}" +} + +# Imprime « ID HEURE » du candidat, ou rien. Code 2 : depot illisible — et c'est une +# ERREUR, pas une absence : conclure « rien a restaurer » parce que le dépôt ne repond pas +# ferait marquer « neuf » une machine dont l'etat attend chez l'hebergeur. +candidat() { + local impose="${1:-}" json rc + set +e + json=$(restic snapshots --host "${MOI}" --json 2>/dev/null) + rc=$? + set -e + # restic 0.17+ : 10 = le depot n'existe pas. Premiere vie de l'ecosysteme. + if (( rc == 10 )); then return 0; fi + if (( rc != 0 )); then dire "depot illisible (restic rc=${rc}) : ${RESTIC_REPOSITORY}"; return 2; fi + printf '%s' "${json}" | python3 -c ' +import datetime, json, re, sys +naissance, impose = int(sys.argv[1]), sys.argv[2] +def t(s): + s = re.sub(r"(\.\d{6})\d+", r"\1", s) + return datetime.datetime.fromisoformat(re.sub(r"Z$", "+00:00", s)).timestamp() +snaps = json.load(sys.stdin) or [] +if impose: + c = [x for x in snaps if impose in (x["short_id"], x["id"])] + if not c: + sys.exit("instantane impose introuvable pour cet hote : " + impose) +else: + c = [x for x in snaps if t(x["time"]) < naissance] +c.sort(key=lambda x: t(x["time"])) +if c: + print(c[-1]["short_id"], c[-1]["time"][:19]) +' "$(naissance)" "${impose}" +} + +# Instantane a utiliser : impose, sinon le candidat. Vide = rien a restaurer. +INSTANTANE="" +resoudre_instantane() { + local c + c=$(candidat "${INSTANTANE}") || exit 2 + INSTANTANE="${c%% *}" +} + +# Le repertoire jetable ou l'instantane est extrait. Detruit a la sortie, quoi qu'il arrive. +TMP="" +trap 'if [[ -n "${TMP}" ]]; then rm -rf "${TMP}"; fi' EXIT + +extraire() { # remplit TMP avec les chemins demandes de l'instantane + local inc=() + TMP=$(mktemp -d /var/tmp/setops-restauration.XXXXXX) + for c in "$@"; do inc+=(--include "${c}"); done + restic restore "${INSTANTANE}" --target "${TMP}" "${inc[@]}" >/dev/null \ + || mourir "restic restore ${INSTANTANE} a echoue" +} + +MIS_DE_COTE="" +mettre_de_cote() { # $1 = chemin vivant ; MIS_DE_COTE = ou il est parti + MIS_DE_COTE="${MISE_DE_COTE}/$(date +%Y%m%d-%H%M%S)$1" + mkdir -p "$(dirname "${MIS_DE_COTE}")" + cp -a "$1" "${MIS_DE_COTE}" + dire "etat en place mis de cote : ${MIS_DE_COTE}" +} + +vide() { # vrai si le chemin est absent, ou un repertoire sans contenu + [[ ! -e "$1" ]] || { [[ -d "$1" ]] && [[ -z "$(find "$1" -mindepth 1 -print -quit)" ]]; } +} + +cmd_fichiers() { + local proprio="" remplacer=0 vers="" chemins=() src dest n + while (( $# )); do + case "$1" in + --proprietaire) proprio="$2"; shift 2 ;; + --remplacer) remplacer=1; shift ;; + --vers) vers="$2"; shift 2 ;; + --instantane) INSTANTANE="$2"; shift 2 ;; + *) chemins+=("$1"); shift ;; + esac + done + [[ -n "${chemins[*]:-}" ]] || mourir "fichiers : aucun chemin" + resoudre_instantane + if [[ -z "${INSTANTANE}" ]]; then echo "RIEN : aucune incarnation precedente."; return 0; fi + extraire "${chemins[@]}" + for c in "${chemins[@]}"; do + src="${TMP}${c}" + dest="${vers}${c}" + if [[ ! -e "${src}" ]]; then echo "ABSENT de ${INSTANTANE} : ${c}"; continue; fi + if ! vide "${dest}"; then + if [[ -z "${vers}" ]] && (( ! remplacer )); then + mourir "${dest} n'est pas vide : --remplacer pour l'ecraser (il sera mis de cote)" + fi + mettre_de_cote "${dest}" + fi + mkdir -p "$(dirname "${dest}")" + if [[ -d "${src}" ]]; then + mkdir -p "${dest}" + rsync -a --delete "${src}/" "${dest}/" + else + cp -a "${src}" "${dest}" + fi + if [[ -n "${proprio}" ]]; then chown -R "${proprio}" "${dest}"; fi + n=$(find "${dest}" -type f | wc -l) + echo "RESTAURE ${INSTANTANE} : ${c} -> ${dest} (${n} fichier(s))" + done +} + +cmd_annuaire() { + local essai=0 base="" ldif dir n + while (( $# )); do + case "$1" in + --essai) essai=1; shift ;; + --instantane) INSTANTANE="$2"; shift 2 ;; + *) base="$1"; shift ;; + esac + done + [[ -n "${base}" ]] || mourir "annuaire : BASE_DN requis" + resoudre_instantane + if [[ -z "${INSTANTANE}" ]]; then echo "RIEN : aucune incarnation precedente."; return 0; fi + extraire "$(dirname "${LDIF}")" + ldif="${TMP}${LDIF}" + [[ -s "${ldif}" ]] || mourir "${INSTANTANE} n'emporte pas d'annuaire (${LDIF})" + n=$(grep -c '^dn:' "${ldif}") + # A BLANC D'ABORD : un LDIF que le schema EN PLACE refuse (overlay pas encore charge, + # attribut inconnu) doit echouer ici, pas apres qu'on a deplace la base vivante. + slapadd -u -q -b "${base}" -l "${ldif}" || mourir "slapadd a blanc refuse ${INSTANTANE}" + if (( essai )); then echo "ESSAI OK : ${n} entree(s) rejouables depuis ${INSTANTANE}."; return 0; fi + dir=$(slapcat -n 0 -a "(olcSuffix=${base})" | awk '/^olcDbDirectory:/{print $2}') + [[ -d "${dir}" ]] || mourir "repertoire de la base introuvable pour ${base}" + systemctl stop slapd + mettre_de_cote "${dir}" + find "${dir}" -mindepth 1 -delete + if ! slapadd -q -b "${base}" -l "${ldif}"; then + dire "slapadd a echoue : remise de la base d'avant" + find "${dir}" -mindepth 1 -delete + cp -a "${MIS_DE_COTE}/." "${dir}/" + chown -R openldap:openldap "${dir}" + systemctl start slapd + exit 1 + fi + chown -R openldap:openldap "${dir}" + systemctl start slapd + echo "RESTAURE ${INSTANTANE} : annuaire ${base}, ${n} entree(s)." +} + +tables() { # nombre de tables hors schemas systeme ; -1 si la base n'existe pas + runuser -u postgres -- psql -tAX -d "$1" -c \ + "select count(*) from information_schema.tables where table_schema not in ('pg_catalog','information_schema')" \ + 2>/dev/null || echo -1 +} + +cmd_base() { + local remplacer=0 vers="" nom="" cible dump section creation proprio n + while (( $# )); do + case "$1" in + --remplacer) remplacer=1; shift ;; + --vers) vers="$2"; shift 2 ;; + --instantane) INSTANTANE="$2"; shift 2 ;; + *) nom="$1"; shift ;; + esac + done + [[ -n "${nom}" ]] || mourir "base : NOM requis" + cible="${vers:-${nom}}" + resoudre_instantane + if [[ -z "${INSTANTANE}" ]]; then echo "RIEN : aucune incarnation precedente."; return 0; fi + extraire "$(dirname "${DUMP_PG}")" + dump="${TMP}${DUMP_PG}" + [[ -s "${dump}" ]] || mourir "${INSTANTANE} n'emporte pas de dump PostgreSQL" + creation=$(grep -m1 -E "^CREATE DATABASE ${nom} " "${dump}" || true) + if [[ -z "${creation}" ]]; then echo "ABSENTE de ${INSTANTANE} : base ${nom}"; return 0; fi + proprio=$(grep -m1 -oP "^ALTER DATABASE ${nom} OWNER TO \K[^;]+" "${dump}" || echo postgres) + # LE PIEGE DE pg_dumpall (runbook §5) : un `CREATE DATABASE ` et, avec + # `--clean`, un `DROP DATABASE postgres;` precedent le `\connect` suivant. On coupe sur + # les trois, puis on REFUSE toute section qui viserait encore une autre base. + section="${TMP}/section.sql" + awk -v db="${nom}" '$0 == "\\connect " db {f=1; next} + f && (/^\\connect / || /^CREATE DATABASE / || /^DROP DATABASE /) {exit} f' "${dump}" > "${section}" + if grep -qE '^(DROP|CREATE|ALTER) DATABASE|^\\connect' "${section}"; then + mourir "section ${nom} hors perimetre : refus" + fi + if [[ -n "${vers}" ]]; then sed -i -E "/ ON DATABASE ${nom} /d" "${section}"; fi + n=$(tables "${cible}") + if (( n > 0 )) && (( ! remplacer )); then + mourir "la base ${cible} contient ${n} table(s) : --remplacer pour l'ecraser (elle sera mise de cote)" + fi + if (( n >= 0 )); then + mkdir -p "${MISE_DE_COTE}" + runuser -u postgres -- pg_dump -Fc "${cible}" > "${MISE_DE_COTE}/$(date +%Y%m%d-%H%M%S)-${cible}.dump" + runuser -u postgres -- dropdb --force "${cible}" + fi + runuser -u postgres -- psql -X -q -v ON_ERROR_STOP=1 -d postgres \ + -c "${creation/CREATE DATABASE ${nom} /CREATE DATABASE ${cible} }" \ + -c "ALTER DATABASE ${cible} OWNER TO ${proprio};" + # UNE SEULE TRANSACTION : une erreur au milieu laisse une base VIDE, que la prochaine + # passe retentera — pas une base a moitie chargee qu'on prendrait pour la bonne. + runuser -u postgres -- psql -X -q --single-transaction -v ON_ERROR_STOP=1 \ + -d "${cible}" -f "${section}" >/dev/null + echo "RESTAURE ${INSTANTANE} : base ${nom} -> ${cible}, $(tables "${cible}") table(s)." +} + +cmd_acter() { + local jeu="${1:?jeu}" etat="${2:?etat}" id="${3:-}" + install -d -m 0700 "${MARQUES}" + printf 'etat=%s\ninstantane=%s\ndate=%s\n' "${etat}" "${id}" "$(date -Is)" > "${MARQUES}/${jeu}" + echo "ACTE ${jeu} : ${etat}${id:+ (${id})}" +} + +cmd_choisir() { + [[ "${1:-}" == "--instantane" ]] && INSTANTANE="${2:-}" + local c + c=$(candidat "${INSTANTANE}") || exit 2 + python3 -c 'import json,sys; print(json.dumps({"instantane": sys.argv[1], "heure": sys.argv[2], "naissance": int(sys.argv[3])}))' \ + "${c%% *}" "$( [[ -n "${c}" ]] && printf '%s' "${c#* }" )" "$(naissance)" +} + +cmd_etat() { + local c + echo "hote : ${MOI}" + echo "naissance : $(date -d "@$(naissance)" -Is)" + c=$(candidat "") || exit 2 + # (Pas d'apostrophe dans `${c:-...}` : entre guillemets doubles, bash l'y lit comme un + # guillemet ouvrant, et tout le script devient illisible.) + if [[ -z "${c}" ]]; then c="aucun (premiere vie, ou rien avant la naissance)"; fi + echo "candidat : ${c}" + for j in "${JEUX[@]}"; do + if [[ -f "${MARQUES}/${j}" ]]; then + echo "jeu ${j} : $(tr '\n' ' ' < "${MARQUES}/${j}")" + else + echo "jeu ${j} : EN ATTENTE (aucun marqueur)" + fi + done +} + +cmd_garde() { + local attente=() c + for j in "${JEUX[@]}"; do + [[ -f "${MARQUES}/${j}" ]] || attente+=("${j}") + done + if [[ -z "${attente[*]:-}" ]]; then return 0; fi + c=$(candidat "") || exit 2 + if [[ -z "${c}" ]]; then + for j in "${attente[@]}"; do cmd_acter "${j}" neuf >/dev/null; done + return 0 + fi + dire "REFUS : l'etat d'une incarnation precedente (${c}) attend dans le depot, non remis" + dire "pour : ${attente[*]}. Deposer maintenant pousserait l'instantane d'avant hors de" + dire "la retention. Le remettre (redeployer le role proprietaire), ou l'ecarter :" + dire " make restauration-renoncer HOTE=${MOI} JEU= CONFIRMER=true" + exit 3 +} + +sous="${1:-etat}"; shift || true +case "${sous}" in + etat) cmd_etat ;; + choisir) cmd_choisir "$@" ;; + fichiers) cmd_fichiers "$@" ;; + annuaire) cmd_annuaire "$@" ;; + base) cmd_base "$@" ;; + acter) cmd_acter "$@" ;; + garde) cmd_garde ;; + *) mourir "sous-commande inconnue : ${sous}" ;; +esac diff --git a/roles/client_backup/templates/sauvegarder.sh.j2 b/roles/client_backup/templates/sauvegarder.sh.j2 index 2ac0974..27f8aed 100644 --- a/roles/client_backup/templates/sauvegarder.sh.j2 +++ b/roles/client_backup/templates/sauvegarder.sh.j2 @@ -5,6 +5,13 @@ set -euo pipefail export RESTIC_REPOSITORY="{{ client_backup_repo }}" export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass" +# 0. UN ETAT D'AVANT ATTEND-IL ? (2026-09-30) +# Sur une machine reconstruite, tant que l'etat de l'incarnation precedente n'a ete ni +# remis ni ecarte, deposer serait NUISIBLE : `restic forget --keep-daily` garde un +# instantane par jour, et celui de l'etat neuf chasserait celui d'avant, s'ils tombent le +# meme jour. On refuse donc, bruyamment (code 3). Voir `setops-restaurer garde`. +/usr/local/sbin/setops-restaurer garde + # 1. Dumps applicatifs (pg_dump, slapcat, forgejo dump...) vers le staging. {% for job in client_backup_jobs %} {% if job.commande is defined %} diff --git a/roles/serveur_dovecot/tasks/main.yml b/roles/serveur_dovecot/tasks/main.yml index 2916f5d..ddcd6c6 100644 --- a/roles/serveur_dovecot/tasks/main.yml +++ b/roles/serveur_dovecot/tasks/main.yml @@ -51,6 +51,41 @@ group: "{{ serveur_dovecot_vmail_utilisateur }}" mode: "0750" +# LES BOITES D'UNE INCARNATION PRECEDENTE (2026-09-30). Remises tant que le repertoire est +# vide — avant que la premiere livraison n'y cree quoi que ce soit. +- name: Restauration — les boites sont-elles vierges ? + ansible.builtin.find: + paths: "{{ serveur_dovecot_vmail_base }}" + file_type: any + register: serveur_dovecot_vmail_initial + +- name: Restauration — serveur_dovecot d'une incarnation precedente ? + ansible.builtin.include_role: + name: client_backup + tasks_from: restaurer.yml + vars: + client_backup_restaurer_jeu: serveur_dovecot + client_backup_restaurer_vierge: "{{ serveur_dovecot_vmail_initial.matched | default(1) == 0 }}" + +- name: Restauration — remettre serveur_dovecot + changed_when: true + ansible.builtin.command: + argv: >- + {{ ['/usr/local/sbin/setops-restaurer', 'fichiers', + '--instantane', client_backup_restauration.instantane] + + ['--proprietaire', serveur_dovecot_vmail_utilisateur + ':' + serveur_dovecot_vmail_utilisateur] + + client_backup_restauration.chemins }} + when: client_backup_restauration.etat == 'a_restaurer' + +- name: Restauration — acter serveur_dovecot + ansible.builtin.include_role: + name: client_backup + tasks_from: acter.yml + vars: + client_backup_acter_etat: restaure + when: client_backup_restauration.etat == 'a_restaurer' + + # --- Pont de certificat step_ca (déposé par client_pki) --- - name: TLS — créer le répertoire des certificats ansible.builtin.file: diff --git a/roles/serveur_forgejo/tasks/main.yml b/roles/serveur_forgejo/tasks/main.yml index 7e480ab..fe65734 100644 --- a/roles/serveur_forgejo/tasks/main.yml +++ b/roles/serveur_forgejo/tasks/main.yml @@ -273,6 +273,54 @@ state: link notify: Redemarrer forgejo +# LES DEPOTS D'UNE INCARNATION PRECEDENTE (2026-09-30). Avant que le role ne cree +# l'arborescence : ensuite, le repertoire ne serait plus vierge. La base, elle, a deja +# ete remise par `serveur_postgresql` (ou vit dans ces fichiers, en SQLite). La +# configuration revient avec le secret JWT que le role relit plus bas. +- name: Restauration — les donnees de Forgejo sont-elles vierges ? + ansible.builtin.find: + paths: "{{ serveur_forgejo_data }}" + file_type: any + register: serveur_forgejo_data_initial + +- name: Restauration — serveur_forgejo d'une incarnation precedente ? + ansible.builtin.include_role: + name: client_backup + tasks_from: restaurer.yml + vars: + client_backup_restaurer_jeu: serveur_forgejo + client_backup_restaurer_vierge: "{{ serveur_forgejo_data_initial.matched | default(1) == 0 }}" + +- name: Restauration — remettre serveur_forgejo + changed_when: true + ansible.builtin.command: + argv: >- + {{ ['/usr/local/sbin/setops-restaurer', 'fichiers', + '--instantane', client_backup_restauration.instantane] + + client_backup_restauration.chemins }} + when: client_backup_restauration.etat == 'a_restaurer' + +# restic rend les proprietaires NUMERIQUES ; `git` n'a pas forcement le meme uid que sur +# la machine d'avant. +- name: Restauration — rendre les donnees au service + ansible.builtin.file: + path: "{{ item.chemin }}" + owner: "{{ item.proprietaire }}" + group: "{{ serveur_forgejo_utilisateur }}" + recurse: true + loop: + - { chemin: "{{ serveur_forgejo_data }}", proprietaire: "{{ serveur_forgejo_utilisateur }}" } + - { chemin: "{{ serveur_forgejo_config_dir }}", proprietaire: root } + when: client_backup_restauration.etat == 'a_restaurer' + +- name: Restauration — acter serveur_forgejo + ansible.builtin.include_role: + name: client_backup + tasks_from: acter.yml + vars: + client_backup_acter_etat: restaure + when: client_backup_restauration.etat == 'a_restaurer' + - name: Creer les repertoires de donnees ansible.builtin.file: path: "{{ serveur_forgejo_data }}/{{ item }}" diff --git a/roles/serveur_nextcloud/tasks/main.yml b/roles/serveur_nextcloud/tasks/main.yml index 766f4e7..4bf8ed7 100644 --- a/roles/serveur_nextcloud/tasks/main.yml +++ b/roles/serveur_nextcloud/tasks/main.yml @@ -27,6 +27,39 @@ serveur_nextcloud_db_port: "{{ resoudre_base_db_port }}" no_log: true +# ETAT DE DEPART, RELEVE AVANT L'INSTALLATION : apres, la configuration existerait et la +# base aurait ses tables — on ne saurait plus si l'on part de rien. Voir `restaurer.yml`. +- name: Restauration — la configuration existe-t-elle deja ? + ansible.builtin.stat: + path: "{{ serveur_nextcloud_racine }}/config/config.php" + register: serveur_nextcloud_config_initiale + +- name: Restauration — le serveur de base de Nextcloud + ansible.builtin.set_fact: + serveur_nextcloud_hote_bd: "{{ (serveurs_bd | default({}))[resoudre_base_entree.serveur].hote }}" + serveur_nextcloud_nom_bd: "{{ resoudre_base_entree.base | default(serveur_nextcloud_db_name) }}" + +- name: Restauration — la base de Nextcloud a-t-elle deja des tables ? + ansible.builtin.command: + argv: + - runuser + - -u + - postgres + - -- + - psql + - -tAX + - -d + - "{{ serveur_nextcloud_nom_bd }}" + - -c + - >- + select count(*) from information_schema.tables + where table_schema not in ('pg_catalog','information_schema') + delegate_to: "{{ serveur_nextcloud_hote_bd }}" + register: serveur_nextcloud_tables_initiales + changed_when: false + check_mode: false + failed_when: false + - name: Paquets (PHP-FPM + extensions, nginx, redis) ansible.builtin.import_tasks: paquets.yml @@ -57,6 +90,9 @@ # --- Sonde de supervision (docs/supervision-conception.md) -------------------------- # Le role qui possede la verite depose sa propre sonde ; le porteur (`client_sante`) la # fait tourner et pousse le verdict, sans savoir ce qu'elle mesure. +- name: Etat d'une incarnation precedente (base, fichiers, configuration) + ansible.builtin.import_tasks: restaurer.yml + - name: Assurer le repertoire des sondes de supervision ansible.builtin.file: path: /usr/local/lib/setops/sondes diff --git a/roles/serveur_nextcloud/tasks/restaurer.yml b/roles/serveur_nextcloud/tasks/restaurer.yml new file mode 100644 index 0000000..14a5f29 --- /dev/null +++ b/roles/serveur_nextcloud/tasks/restaurer.yml @@ -0,0 +1,129 @@ +--- +# NEXTCLOUD D'UNE INCARNATION PRECEDENTE (2026-09-30) +# +# EN FIN DE ROLE, PAS AVANT L'INSTALLATION. Le code des applications ajoutees +# (`user_oidc`, `richdocuments`) n'est pas sauvegarde : il se reinstalle. Restauree +# AVANT, la base les dirait installees sans qu'elles le soient, et `occ app:install` +# refuserait (« already installed »). On laisse donc le role tout poser a neuf, puis on +# remplace, ENSEMBLE, la base, les fichiers et la configuration — les secrets de +# l'instance (`instanceid`, `passwordsalt`, `secret`) voyagent avec ses donnees. +# C'est la procedure faite a la main sur Technolibre le 2026-09-30. +# +# LA BASE N'EST REMISE QUE SI ELLE ETAIT VIDE AU DEPART. Une configuration absente sur une +# base deja peuplee, c'est une machine refaite seule a cote d'une base vivante : la +# remplacer effacerait ce qui a ete ecrit depuis la sauvegarde. On refuse, et on dit quoi +# faire. +- name: Restauration — Nextcloud d'une incarnation precedente ? + ansible.builtin.include_role: + name: client_backup + tasks_from: restaurer.yml + vars: + client_backup_restaurer_jeu: serveur_nextcloud + client_backup_restaurer_vierge: "{{ not serveur_nextcloud_config_initiale.stat.exists }}" + +- name: Restauration — remettre Nextcloud + when: client_backup_restauration.etat == 'a_restaurer' + block: + - name: Restauration — refuser d'ecraser une base vivante + ansible.builtin.assert: + that: + - serveur_nextcloud_tables_initiales.rc | default(1) == 0 + - (serveur_nextcloud_tables_initiales.stdout | default('1') | int) == 0 + fail_msg: >- + Nextcloud n'avait pas de configuration, mais sa base {{ serveur_nextcloud_nom_bd }} + sur {{ serveur_nextcloud_hote_bd }} n'etait pas vide (ou illisible). Ce n'est pas + une reconstruction complete : ne remettre que les fichiers, a la main — + `setops-restaurer fichiers --remplacer ...` —, ou ecarter cet etat : + make restauration-renoncer HOTE={{ inventory_hostname }} JEU=nextcloud CONFIRMER=true + + - name: Restauration — mode maintenance + changed_when: true + ansible.builtin.command: + argv: [php, "{{ serveur_nextcloud_racine }}/occ", maintenance:mode, --on] + become: true + become_user: "{{ serveur_nextcloud_utilisateur }}" + + - name: Restauration — arreter PHP et les taches de fond + ansible.builtin.systemd: + name: "{{ item }}" + state: stopped + loop: ["php{{ serveur_nextcloud_php_version }}-fpm", cron] + + # La base D'ABORD, et on lit ce qu'elle repond : un « RIEN » (pas d'incarnation + # precedente cote base) ne doit pas laisser remettre des fichiers qui ne lui + # correspondraient plus. + - name: Restauration — remettre la base (sur son serveur, avec SON instantane) + changed_when: true + ansible.builtin.command: + argv: + - /usr/local/sbin/setops-restaurer + - base + - --remplacer + - "{{ serveur_nextcloud_nom_bd }}" + delegate_to: "{{ serveur_nextcloud_hote_bd }}" + register: serveur_nextcloud_base_remise + failed_when: >- + serveur_nextcloud_base_remise.rc != 0 + or 'RESTAURE' not in serveur_nextcloud_base_remise.stdout + + - name: Restauration — remettre les fichiers et la configuration + changed_when: true + ansible.builtin.command: + argv: >- + {{ ['/usr/local/sbin/setops-restaurer', 'fichiers', '--remplacer', + '--instantane', client_backup_restauration.instantane, + '--proprietaire', serveur_nextcloud_utilisateur + ':' + serveur_nextcloud_utilisateur] + + client_backup_restauration.chemins }} + + - name: Restauration — relancer PHP et les taches de fond + ansible.builtin.systemd: + name: "{{ item }}" + state: started + loop: ["php{{ serveur_nextcloud_php_version }}-fpm", cron] + + - name: Restauration — l'etat restaure demande-t-il une mise a niveau ? + ansible.builtin.command: + argv: [php, "{{ serveur_nextcloud_racine }}/occ", status, --output=json] + become: true + become_user: "{{ serveur_nextcloud_utilisateur }}" + register: serveur_nextcloud_statut_restaure + changed_when: false + + # L'installation neuve porte des applications parfois plus recentes que l'instantane + # (richdocuments, le 2026-09-30) : `occ upgrade` aligne leurs schemas. + - name: Restauration — mise a niveau des schemas + changed_when: true + ansible.builtin.command: + argv: [php, "{{ serveur_nextcloud_racine }}/occ", upgrade] + become: true + become_user: "{{ serveur_nextcloud_utilisateur }}" + # Le JSON est la DERNIERE ligne : quand une mise a niveau est due, `occ` l'annonce + # d'abord en clair. + when: >- + (serveur_nextcloud_statut_restaure.stdout_lines | last | default('{}') | from_json).needsDbUpgrade + | default(false) + + - name: Restauration — sortir du mode maintenance + changed_when: true + ansible.builtin.command: + argv: [php, "{{ serveur_nextcloud_racine }}/occ", maintenance:mode, --off] + become: true + become_user: "{{ serveur_nextcloud_utilisateur }}" + + - name: Restauration — Nextcloud est-il revenu ? + ansible.builtin.command: + argv: [php, "{{ serveur_nextcloud_racine }}/occ", status, --output=json] + become: true + become_user: "{{ serveur_nextcloud_utilisateur }}" + register: serveur_nextcloud_statut_final + changed_when: false + failed_when: >- + not ((serveur_nextcloud_statut_final.stdout_lines | last | default('{}') | from_json).installed | default(false)) + or ((serveur_nextcloud_statut_final.stdout_lines | last | default('{}') | from_json).needsDbUpgrade | default(true)) + + - name: Restauration — acter Nextcloud remis + ansible.builtin.include_role: + name: client_backup + tasks_from: acter.yml + vars: + client_backup_acter_etat: restaure diff --git a/roles/serveur_openldap/tasks/main.yml b/roles/serveur_openldap/tasks/main.yml index 1503939..f339a0c 100644 --- a/roles/serveur_openldap/tasks/main.yml +++ b/roles/serveur_openldap/tasks/main.yml @@ -54,6 +54,21 @@ enabled: true state: started +# L'ANNUAIRE EST-IL VIERGE ? Mesure AVANT que ce role n'y cree quoi que ce soit (unites, +# comptes de service) : apres, il ne le serait plus jamais. Le paquet ne pose que la +# racine — `cn=admin` n'y figure que sur les installations anciennes. +- name: Restauration — l'annuaire est-il vierge ? + ansible.builtin.shell: >- + set -o pipefail; + slapcat -b '{{ serveur_openldap_base_dn }}' 2>/dev/null | grep '^dn:' + | grep -v -i -x -e 'dn: {{ serveur_openldap_base_dn }}' -e 'dn: cn=admin,{{ serveur_openldap_base_dn }}' + | wc -l + args: + executable: /bin/bash + register: serveur_openldap_entrees_initiales + changed_when: false + check_mode: false + # LA ROTATION PASSE AVANT TOUT CE QUI SE LIE EN ADMINISTRATEUR (2026-09-13). # # Placee plus bas, elle arrivait trop tard : « Creer les unites organisationnelles » se @@ -443,6 +458,57 @@ - serveur_openldap_cert_pont.stat.exists | default(false) notify: Redemarrer slapd +# --- L'ANNUAIRE D'UNE INCARNATION PRECEDENTE (2026-09-30) --------------------------- +# +# EN FIN DE ROLE, PAS AU DEBUT. Le LDIF porte des attributs de `ppolicy` (`pwdChangedTime`, +# `pwdReset`...) que slapd refuse tant que l'overlay n'est pas charge — c'est-a-dire plus +# haut dans ce role. On remplace donc ce que le role vient de creer par ce qui vivait +# avant ; `setops-restaurer` le rejoue d'abord a blanc, et met la base neuve de cote. +# +# Sans ceci, une reconstruction rendait a `sysadmin` le mot de passe d'amorcage : c'est +# ainsi que l'absence de restauration a ete decouverte, le 2026-09-30. +- name: Restauration — l'annuaire d'une incarnation precedente ? + ansible.builtin.include_role: + name: client_backup + tasks_from: restaurer.yml + vars: + client_backup_restaurer_jeu: serveur_openldap + client_backup_restaurer_vierge: "{{ (serveur_openldap_entrees_initiales.stdout | default('1') | int) == 0 }}" + +- name: Restauration — remettre l'annuaire + changed_when: true + ansible.builtin.command: + argv: + - /usr/local/sbin/setops-restaurer + - annuaire + - --instantane + - "{{ client_backup_restauration.instantane }}" + - "{{ serveur_openldap_base_dn }}" + when: client_backup_restauration.etat == 'a_restaurer' + +# Les comptes de service reviennent avec le mot de passe de l'EPOQUE. On les realigne sur +# la voute tout de suite : Keycloak, Postfix et Dovecot s'y lient avec la valeur actuelle. +- name: Restauration — realigner les comptes de service sur la voute + community.general.ldap_passwd: + dn: "cn={{ item.cn }},{{ serveur_openldap_services_dn }}" + passwd: "{{ lookup('vars', item.secret) }}" + server_uri: "ldapi:///" + bind_dn: "{{ serveur_openldap_admin_dn }}" + bind_pw: "{{ serveur_openldap_admin_password }}" + loop: "{{ serveur_openldap_comptes_service }}" + loop_control: + label: "{{ item.cn }}" + no_log: true + when: client_backup_restauration.etat == 'a_restaurer' + +- name: Restauration — acter l'annuaire remis + ansible.builtin.include_role: + name: client_backup + tasks_from: acter.yml + vars: + client_backup_acter_etat: restaure + when: client_backup_restauration.etat == 'a_restaurer' + # --- Sonde de supervision (docs/supervision-conception.md) -------------------------- # Le role qui possede la verite depose sa propre sonde ; le porteur (`client_sante`) la # fait tourner et pousse le verdict, sans savoir ce qu'elle mesure. diff --git a/roles/serveur_postgresql/defaults/main.yml b/roles/serveur_postgresql/defaults/main.yml index d301f7f..2ee0abf 100644 --- a/roles/serveur_postgresql/defaults/main.yml +++ b/roles/serveur_postgresql/defaults/main.yml @@ -72,3 +72,25 @@ serveur_postgresql_exportateur_service: prometheus-postgres-exporter serveur_postgresql_exportateur_port: 9187 serveur_postgresql_exportateur_utilisateur: setops_metriques serveur_postgresql_exportateur_mot_de_passe: "{{ vault_pg_exportateur | default('') }}" + +# --- RESTAURATION (2026-09-30) — voir tasks/main.yml --------------------------------- +# +# Les bases que CE role ne rejoue pas, designees par leur `consommateur` au registre : +# - nextcloud : il se remet LUI-MEME, en fin de son role. Sa base, ses fichiers et sa +# configuration vont ensemble, et le code de ses applications (user_oidc, +# richdocuments) n'est pas sauvegarde : restauree avant son installation, la base +# les dirait installees sans qu'elles le soient, et `occ app:install` refuserait ; +# - icinga : l'historique de supervision, lie a l'environnement d'Icinga, que rien ne +# sauvegarde. Il repart de zero, comme le reste de la supervision. +serveur_postgresql_restauration_consommateurs_exclus: + - nextcloud + - icinga + +serveur_postgresql_bases_restaurables: >- + {{ ((serveur_postgresql_registre | default([])) + | rejectattr('value.consommateur', 'defined') | map(attribute='value.base') | list) + + ((serveur_postgresql_registre | default([])) + | selectattr('value.consommateur', 'defined') + | rejectattr('value.consommateur', 'in', serveur_postgresql_restauration_consommateurs_exclus) + | map(attribute='value.base') | list) + + (serveur_postgresql_bases | map(attribute='nom') | list) }} diff --git a/roles/serveur_postgresql/tasks/main.yml b/roles/serveur_postgresql/tasks/main.yml index a4a8e98..8305db9 100644 --- a/roles/serveur_postgresql/tasks/main.yml +++ b/roles/serveur_postgresql/tasks/main.yml @@ -216,6 +216,68 @@ label: "{{ item.value.base }}" when: serveur_postgresql_registre | length > 0 +# --- LES BASES D'UNE INCARNATION PRECEDENTE (2026-09-30) ---------------------------- +# +# ICI, et pas plus tard : les bases viennent d'etre creees, leurs consommateurs (Keycloak, +# Forgejo...) ne les ont pas encore touchees. Chaque base VIDE recoit sa section du +# `pg_dumpall` d'avant, en une transaction ; une base qui a deja des tables n'est jamais +# ecrasee d'office. Les roles et leurs mots de passe restent ceux de la voute : la section +# d'une base ne les porte pas. +- name: Restauration — quelles bases sont vierges ? + ansible.builtin.command: + argv: + - psql + - -tAX + - -d + - "{{ item }}" + - -c + - >- + select count(*) from information_schema.tables + where table_schema not in ('pg_catalog','information_schema') + become: true + become_user: postgres + loop: "{{ serveur_postgresql_bases_restaurables }}" + register: serveur_postgresql_tables_initiales + changed_when: false + check_mode: false + failed_when: false + +- name: Restauration — retenir les bases vierges + ansible.builtin.set_fact: + serveur_postgresql_bases_vierges: >- + {{ serveur_postgresql_tables_initiales.results + | selectattr('rc', 'equalto', 0) + | selectattr('stdout', 'equalto', '0') + | map(attribute='item') | list }} + +- name: Restauration — les bases d'une incarnation precedente ? + ansible.builtin.include_role: + name: client_backup + tasks_from: restaurer.yml + vars: + client_backup_restaurer_jeu: serveur_postgresql + client_backup_restaurer_vierge: "{{ serveur_postgresql_bases_vierges | length > 0 }}" + +- name: Restauration — rejouer chaque base vierge + changed_when: true + ansible.builtin.command: + argv: + - /usr/local/sbin/setops-restaurer + - base + - --instantane + - "{{ client_backup_restauration.instantane }}" + - "{{ item }}" + loop: "{{ serveur_postgresql_bases_vierges }}" + when: client_backup_restauration.etat == 'a_restaurer' + +- name: Restauration — acter les bases remises + ansible.builtin.include_role: + name: client_backup + tasks_from: acter.yml + vars: + client_backup_acter_etat: restaure + when: client_backup_restauration.etat == 'a_restaurer' + # --- Sonde de supervision (docs/supervision-conception.md) -------------------------- # Le role qui possede la verite depose sa propre sonde ; le porteur (`client_sante`) la # fait tourner et pousse le verdict, sans savoir ce qu'elle mesure. diff --git a/roles/serveur_rspamd/tasks/main.yml b/roles/serveur_rspamd/tasks/main.yml index f139593..7813b6d 100644 --- a/roles/serveur_rspamd/tasks/main.yml +++ b/roles/serveur_rspamd/tasks/main.yml @@ -12,6 +12,55 @@ state: started when: not ansible_check_mode +# CE QUE RSPAMD A APPRIS, ET SA CLE DKIM (2026-09-30). +# +# La cle DKIM se genere « seulement si absente » : chaque reconstruction en fabriquait +# donc une NEUVE, et l'enregistrement DNS publie cessait de correspondre — le courriel +# signe devenait invalide chez les destinataires, sans rien casser chez nous. On la +# remet AVANT la generation. Le paquet a deja peuple `/var/lib/rspamd` : on le remplace +# (mis de cote), rspamd arrete. +- name: Restauration — la cle DKIM existe-t-elle deja ? + ansible.builtin.stat: + path: "{{ serveur_rspamd_dkim_repertoire }}/{{ serveur_rspamd_dkim_domaine }}.{{ serveur_rspamd_dkim_selecteur }}.key" + register: serveur_rspamd_dkim_initiale + +- name: Restauration — rspamd d'une incarnation precedente ? + ansible.builtin.include_role: + name: client_backup + tasks_from: restaurer.yml + vars: + client_backup_restaurer_jeu: serveur_rspamd + client_backup_restaurer_vierge: "{{ not serveur_rspamd_dkim_initiale.stat.exists }}" + +- name: Restauration — remettre rspamd + when: client_backup_restauration.etat == 'a_restaurer' + block: + - name: Restauration — arreter rspamd + ansible.builtin.systemd: + name: "{{ serveur_rspamd_service }}" + state: stopped + + - name: Restauration — remettre son etat et sa cle + changed_when: true + ansible.builtin.command: + argv: >- + {{ ['/usr/local/sbin/setops-restaurer', 'fichiers', '--remplacer', + '--instantane', client_backup_restauration.instantane, + '--proprietaire', '_rspamd:_rspamd'] + + client_backup_restauration.chemins }} + + - name: Restauration — relancer rspamd + ansible.builtin.systemd: + name: "{{ serveur_rspamd_service }}" + state: started + + - name: Restauration — acter rspamd + ansible.builtin.include_role: + name: client_backup + tasks_from: acter.yml + vars: + client_backup_acter_etat: restaure + # --- Clé DKIM (privée locale ; publique à publier en DNS — Étape B) --- - name: Créer le répertoire des clés DKIM ansible.builtin.file: diff --git a/roles/serveur_step_ca/tasks/main.yml b/roles/serveur_step_ca/tasks/main.yml index 836ad50..04d4728 100644 --- a/roles/serveur_step_ca/tasks/main.yml +++ b/roles/serveur_step_ca/tasks/main.yml @@ -76,6 +76,72 @@ create_home: false shell: /usr/sbin/nologin +# L'AC NE RENAIT PAS SI ELLE A DEJA VECU (2026-09-30). +# +# `step ca init` fabriquait une racine neuve a chaque reconstruction : toute confiance +# accordee a l'ancienne (postes, navigateurs, autres ecosystemes) tombait sans bruit. +# Technolibre a change de racine le 2026-09-30, Chezlepro le 2026-09-13. +# +# C'est ICI, avant l'init, que l'AC d'une incarnation precedente doit revenir : ensuite, +# toute la flotte s'enrole aupres d'elle comme d'habitude. Plus tard, il faudrait +# reenroler chaque machine. +- name: L'autorite a-t-elle deja ete initialisee ? + ansible.builtin.stat: + path: "{{ serveur_step_ca_steppath }}/config/ca.json" + register: serveur_step_ca_ca_json + +- name: Restauration — l'autorite d'une incarnation precedente ? + ansible.builtin.include_role: + name: client_backup + tasks_from: restaurer.yml + vars: + client_backup_restaurer_jeu: serveur_step_ca + client_backup_restaurer_vierge: "{{ not serveur_step_ca_ca_json.stat.exists }}" + +- name: Restauration — remettre l'autorite (racine, intermediaire, base des emissions) + changed_when: true + ansible.builtin.command: + argv: + - /usr/local/sbin/setops-restaurer + - fichiers + - --instantane + - "{{ client_backup_restauration.instantane }}" + - --proprietaire + - "{{ serveur_step_ca_utilisateur }}:{{ serveur_step_ca_utilisateur }}" + - "{{ serveur_step_ca_steppath }}" + when: client_backup_restauration.etat == 'a_restaurer' + +# LES CLES SONT CHIFFREES PAR LE MOT DE PASSE DE L'EPOQUE. La tache suivante reecrit +# `password.txt` depuis la voute : si la voute a change depuis, step-ca ne demarrerait +# plus, et l'erreur parlerait de dechiffrement, pas de restauration. +- name: Restauration — lire les mots de passe restaures + ansible.builtin.slurp: + src: "{{ serveur_step_ca_steppath }}/{{ item }}" + loop: [password.txt, provisioner_password.txt] + register: serveur_step_ca_mdp_restaures + no_log: true + when: client_backup_restauration.etat == 'a_restaurer' + +- name: Restauration — la voute ouvre-t-elle encore ces cles ? + ansible.builtin.assert: + that: + - (serveur_step_ca_mdp_restaures.results[0].content | b64decode) == serveur_step_ca_password + - (serveur_step_ca_mdp_restaures.results[1].content | b64decode) == serveur_step_ca_provisioner_password + fail_msg: >- + L'AC restauree ({{ client_backup_restauration.instantane }}) est chiffree par un mot + de passe que la voute ne porte plus. Remettre les anciennes valeurs de + vault_step_ca_password / provisioner dans la voute, ou renoncer a cette AC. + no_log: true + when: client_backup_restauration.etat == 'a_restaurer' + +- name: Restauration — acter l'autorite remise + ansible.builtin.include_role: + name: client_backup + tasks_from: acter.yml + vars: + client_backup_acter_etat: restaure + when: client_backup_restauration.etat == 'a_restaurer' + - name: Creer le repertoire STEPPATH ansible.builtin.file: path: "{{ serveur_step_ca_steppath }}" diff --git a/roles/serveur_web_dorsal/tasks/main.yml b/roles/serveur_web_dorsal/tasks/main.yml index 4e71f81..c8ddbad 100644 --- a/roles/serveur_web_dorsal/tasks/main.yml +++ b/roles/serveur_web_dorsal/tasks/main.yml @@ -26,6 +26,40 @@ group: root mode: "0755" +# CE QUE LE DORSAL PORTAIT (2026-09-30). Les sites clones depuis la forge se refont +# seuls ; ce qui n'y vit pas revient d'ici, tant que la racine est vide. +- name: Restauration — la racine des webapps est-elle vierge ? + ansible.builtin.find: + paths: "{{ serveur_web_dorsal_racine }}" + file_type: any + register: serveur_web_dorsal_racine_initiale + +- name: Restauration — serveur_web_dorsal d'une incarnation precedente ? + ansible.builtin.include_role: + name: client_backup + tasks_from: restaurer.yml + vars: + client_backup_restaurer_jeu: serveur_web_dorsal + client_backup_restaurer_vierge: "{{ serveur_web_dorsal_racine_initiale.matched | default(1) == 0 }}" + +- name: Restauration — remettre serveur_web_dorsal + changed_when: true + ansible.builtin.command: + argv: >- + {{ ['/usr/local/sbin/setops-restaurer', 'fichiers', + '--instantane', client_backup_restauration.instantane] + + client_backup_restauration.chemins }} + when: client_backup_restauration.etat == 'a_restaurer' + +- name: Restauration — acter serveur_web_dorsal + ansible.builtin.include_role: + name: client_backup + tasks_from: acter.yml + vars: + client_backup_acter_etat: restaure + when: client_backup_restauration.etat == 'a_restaurer' + + # LA CONFIANCE AVANT LE CLONE, LIMITEE A LA FORGE DU SITE (voir defaults/main.yml). - name: Creer le repertoire de la racine de la forge du site ansible.builtin.file: diff --git a/roles/serveur_web_frontal/tasks/main.yml b/roles/serveur_web_frontal/tasks/main.yml index 4946f4e..2123dc2 100644 --- a/roles/serveur_web_frontal/tasks/main.yml +++ b/roles/serveur_web_frontal/tasks/main.yml @@ -7,6 +7,40 @@ update_cache: true cache_valid_time: 3600 +# CE QUE LE CATALOGUE DE SAUVEGARDE DIT DU FRONTAL (2026-09-30). Il ne sert plus rien +# lui-meme — il relaie —, mais le catalogue emporte encore `/srv/web` : tant qu'il +# l'emporte, on le remet, quand la racine est vide. +- name: Restauration — la racine du frontal est-elle vierge ? + ansible.builtin.find: + paths: "{{ serveur_web_frontal_racine_base | default('/srv/web') }}" + file_type: any + register: serveur_web_frontal_racine_initiale + +- name: Restauration — serveur_web_frontal d'une incarnation precedente ? + ansible.builtin.include_role: + name: client_backup + tasks_from: restaurer.yml + vars: + client_backup_restaurer_jeu: serveur_web_frontal + client_backup_restaurer_vierge: "{{ serveur_web_frontal_racine_initiale.matched | default(1) == 0 }}" + +- name: Restauration — remettre serveur_web_frontal + changed_when: true + ansible.builtin.command: + argv: >- + {{ ['/usr/local/sbin/setops-restaurer', 'fichiers', + '--instantane', client_backup_restauration.instantane] + + client_backup_restauration.chemins }} + when: client_backup_restauration.etat == 'a_restaurer' + +- name: Restauration — acter serveur_web_frontal + ansible.builtin.include_role: + name: client_backup + tasks_from: acter.yml + vars: + client_backup_acter_etat: restaure + when: client_backup_restauration.etat == 'a_restaurer' + - name: Retirer le vhost nginx par defaut de Debian ansible.builtin.file: path: /etc/nginx/sites-enabled/default diff --git a/scripts/tests/test_restauration.py b/scripts/tests/test_restauration.py new file mode 100644 index 0000000..d150a03 --- /dev/null +++ b/scripts/tests/test_restauration.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""La reconstruction REMET l'etat d'avant — et chaque detenteur d'etat sait le faire. + +POURQUOI (2026-09-30). Technolibre a ete reconstruite, 0 echec, `make valider` vert — et +rien n'etait revenu : ni l'annuaire (le mot de passe de `sysadmin` etait celui de +l'amorcage), ni la racine de l'AC, ni les bases, ni Nextcloud. « Restauration verifiee » +voulait dire « restauree dans un repertoire temporaire, lue, jetee ». + +Ce test verifie deux choses : + +1. COUVERTURE (statique). Chaque groupe detenteur d'etat (`client_backup_groupes_etat`) + inclut `restaurer.yml` depuis son propre role. Une liste qui en suit une autre prend du + retard : ajouter un detenteur d'etat sans son point de restauration doit echouer ICI. + +2. L'OUTIL DE NOEUD (`setops-restaurer`), rendu depuis son vrai gabarit, avec `restic`, + `psql` & co. remplaces par des doublures : + - le candidat est le dernier instantane ANTERIEUR a la naissance de la machine ; + - un repertoire non vide n'est jamais ecrase sans `--remplacer` ; + - la sauvegarde refuse de deposer tant qu'un etat d'avant attend (code 3), et + reprend une fois chaque jeu acte ; un depot inexistant = premiere vie (« neuf ») ; + - la section d'une base s'arrete avant le `DROP DATABASE postgres;` que `pg_dumpall + --clean` place apres la derniere base (le piege du runbook, rencontre le 2026-09-30). +""" +from __future__ import annotations + +import json +import os +import stat +import subprocess +import sys +import tempfile +from pathlib import Path + +import jinja2 +import yaml + +RACINE = Path(__file__).resolve().parents[2] +GABARIT = RACINE / "roles/client_backup/templates/restaurer.sh.j2" + +ECHECS: list[str] = [] + + +def verifier(cond: bool, msg: str) -> None: + print(("OK " if cond else "ECHEC ") + msg) + if not cond: + ECHECS.append(msg) + + +# --- 1. Couverture ------------------------------------------------------------------- + +def couverture() -> None: + groupes = yaml.safe_load((RACINE / "roles/client_backup/vars/main.yml").read_text())[ + "client_backup_groupes_etat"] + for g in groupes: + taches = RACINE / "roles" / g / "tasks" + texte = "".join(p.read_text() for p in sorted(taches.glob("*.yml"))) if taches.is_dir() else "" + verifier(f"client_backup_restaurer_jeu: {g}" in texte, + f"{g} inclut son point de restauration (restaurer.yml)") + verifier("{#" not in GABARIT.read_text(), "le gabarit ne contient aucune sequence accolade-diese") + + +# --- 2. L'outil de noeud, avec des doublures ------------------------------------------ + +def executable(chemin: Path, texte: str) -> None: + chemin.write_text(texte) + chemin.chmod(chemin.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +DUMP = r"""-- +DROP DATABASE nextcloud; +DROP DATABASE keycloak; +CREATE DATABASE keycloak WITH TEMPLATE = template0 ENCODING = 'UTF8' LOCALE = 'fr_CA.UTF-8'; +ALTER DATABASE keycloak OWNER TO keycloak; +\connect keycloak +CREATE TABLE realm (id text); +COPY realm FROM stdin; +\. +CREATE DATABASE nextcloud WITH TEMPLATE = template0 ENCODING = 'UTF8' LOCALE = 'fr_CA.UTF-8'; +ALTER DATABASE nextcloud OWNER TO nextcloud; +\connect nextcloud +CREATE TABLE oc_users (uid text); +REVOKE CONNECT,TEMPORARY ON DATABASE nextcloud FROM PUBLIC; +DROP DATABASE postgres; +CREATE DATABASE postgres WITH TEMPLATE = template0; +\connect postgres +""" + + +def outil() -> None: + with tempfile.TemporaryDirectory() as d: + d = Path(d) + bin_, fixt, log = d / "bin", d / "instantanes", d / "journal" + for p in (bin_, fixt, log): + p.mkdir() + cle = d / "ssh_host_ed25519_key.pub" + cle.write_text("cle\n") # nee MAINTENANT + staging = d / "staging" + + # Deux instantanes : l'un d'AVANT la naissance, l'autre d'apres (l'etat neuf). + avant, apres = fixt / "avant01", fixt / "apres02" + for snap, contenu in ((avant, "ancien"), (apres, "neuf")): + (snap / str(d / "vmail").lstrip("/") / "boite").mkdir(parents=True) + (snap / str(d / "vmail").lstrip("/") / "boite" / "msg").write_text(contenu) + dump = snap / str(staging).lstrip("/") / "postgresql" / "toutes-bases.sql" + dump.parent.mkdir(parents=True) + dump.write_text(DUMP) + snaps = [ + {"short_id": "avant01", "id": "avant01" + "0" * 56, "time": "2026-09-29T23:50:50.123456789-04:00"}, + {"short_id": "apres02", "id": "apres02" + "0" * 56, "time": "2099-01-01T00:00:00Z"}, + ] + (d / "snapshots.json").write_text(json.dumps(snaps)) + + # restic : `snapshots --json` et `restore ID --target T --include P...`. + # SETOPS_TEST_DEPOT_ABSENT=1 imite restic 0.17+ face a un depot inexistant (code 10). + executable(bin_ / "restic", f"""#!/bin/bash +if [[ -n "${{SETOPS_TEST_DEPOT_ABSENT:-}}" ]]; then exit 10; fi +case "$1" in + snapshots) cat "{d}/snapshots.json" ;; + restore) + id="$2"; shift 2; cible=""; inc=() + while (( $# )); do case "$1" in --target) cible="$2"; shift 2;; --include) inc+=("$2"); shift 2;; *) shift;; esac; done + for i in "${{inc[@]}}"; do + [[ -e "{fixt}/$id$i" ]] || continue + mkdir -p "$cible$(dirname "$i")"; cp -a "{fixt}/$id$i" "$cible$(dirname "$i")/" + done ;; +esac +""") + # PostgreSQL : on journalise, on garde la section rejouee ; toute base est vierge. + executable(bin_ / "runuser", '#!/bin/bash\nwhile [[ "$1" != "--" ]]; do shift; done; shift; exec "$@"\n') + executable(bin_ / "psql", f"""#!/bin/bash +echo "psql $*" >> "{log}/pg" +while (( $# )); do + case "$1" in -f) cp "$2" "{log}/section.sql"; shift 2;; -c) [[ "$2" == select* ]] && echo 0; shift 2;; *) shift;; esac +done +""") + executable(bin_ / "pg_dump", '#!/bin/bash\necho dump\n') + executable(bin_ / "dropdb", f'#!/bin/bash\necho "dropdb $*" >> "{log}/pg"\n') + + rendu = jinja2.Environment(undefined=jinja2.StrictUndefined).from_string( + GABARIT.read_text()).render( + client_backup_repo="sftp:restic@depot:hote", + inventory_hostname="hote", + client_backup_restauration_marques=str(d / "marques"), + client_backup_restauration_mise_de_cote=str(d / "mis-de-cote"), + client_backup_staging=str(staging), + client_backup_jobs=[{"nom": "courriel"}, {"nom": "postgresql"}], + ).replace("/etc/ssh/ssh_host_ed25519_key.pub", str(cle)) \ + .replace("/etc/setops/restic.pass", str(d / "restic.pass")) + script = d / "setops-restaurer" + executable(script, rendu) + env = {**os.environ, "PATH": f"{bin_}:{os.environ['PATH']}"} + + def lancer(*args: str, **extra: str) -> subprocess.CompletedProcess: + return subprocess.run(["bash", str(script), *args], env={**env, **extra}, + capture_output=True, text=True) + + r = lancer("choisir") + verifier(r.returncode == 0 and json.loads(r.stdout)["instantane"] == "avant01", + f"le candidat est l'instantane d'AVANT la naissance, pas l'etat neuf ({r.stdout.strip() or r.stderr.strip()})") + + r = lancer("garde") + verifier(r.returncode == 3, f"la sauvegarde refuse tant qu'un etat d'avant attend (code {r.returncode})") + + vmail = d / "vmail" + (vmail / "neuf").mkdir(parents=True) + (vmail / "neuf" / "x").write_text("x") + r = lancer("fichiers", str(vmail)) + verifier(r.returncode != 0 and (vmail / "neuf" / "x").exists(), + "un repertoire non vide n'est pas ecrase sans --remplacer") + + r = lancer("fichiers", "--remplacer", str(vmail)) + verifier(r.returncode == 0 and (vmail / "boite" / "msg").read_text() == "ancien" + and not (vmail / "neuf").exists(), + f"--remplacer remet l'etat d'avant, a l'identique ({r.stdout.strip() or r.stderr.strip()})") + verifier(any((d / "mis-de-cote").rglob("x")), "l'etat ecrase a ete mis de cote") + + r = lancer("base", "nextcloud") + section = (log / "section.sql").read_text() if (log / "section.sql").exists() else "" + verifier(r.returncode == 0 and "oc_users" in section, f"la section nextcloud est rejouee ({r.stderr.strip()[:120]})") + verifier("DROP DATABASE" not in section and "realm" not in section, + "la section s'arrete avant le DROP DATABASE postgres, et ne deborde sur aucune autre base") + pg = (log / "pg").read_text() + verifier("--single-transaction" in pg, "la base est rejouee en une seule transaction") + + r = lancer("base", "inexistante") + verifier(r.returncode == 0 and "ABSENTE" in r.stdout, "une base absente de l'instantane est dite, pas inventee") + + for jeu in ("courriel", "postgresql"): + lancer("acter", jeu, "restaure", "avant01") + r = lancer("garde") + verifier(r.returncode == 0, "la sauvegarde reprend une fois chaque jeu acte") + + for f in (d / "marques").iterdir(): + f.unlink() + r = lancer("garde", SETOPS_TEST_DEPOT_ABSENT="1") + marques = sorted(p.name for p in (d / "marques").iterdir()) + verifier(r.returncode == 0 and marques == ["courriel", "postgresql"] + and "etat=neuf" in (d / "marques" / "courriel").read_text(), + "depot inexistant = premiere vie : chaque jeu est acte « neuf »") + + +def main() -> int: + couverture() + outil() + if ECHECS: + print(f"\n{len(ECHECS)} echec(s).") + return 1 + print("\nLa restauration est couverte et l'outil se comporte comme annonce.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())