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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-30 02:39:22 -04:00
parent d3437a6308
commit 8a9da0c31a
25 changed files with 1544 additions and 57 deletions

View file

@ -1,5 +1,53 @@
# CHANGELOG — Set-OPS # 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 ## 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. **Le constat** : après (45), le mot de passe de `sysadmin` était revenu à celui de l'amorçage.

View file

@ -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_expositions_sans_port.py
python3 scripts/tests/test_proxmox_fw_externe.py python3 scripts/tests/test_proxmox_fw_externe.py
python3 scripts/tests/test_sonde_tcp.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 # 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 # 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 valider: ansible-runtime ## Passe la recette de validation sur la flotte
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/valider.yml 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=<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=<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 .PHONY: schema
schema: ## Regenere docs/audit/schema-plan.json depuis les registres et les validateurs schema: ## Regenere docs/audit/schema-plan.json depuis les registres et les validateurs
python3 scripts/schema_plan.py python3 scripts/schema_plan.py

View file

@ -84,6 +84,8 @@ variables:
DANS: DANS:
invite: "Jours avant le second temps" invite: "Jours avant le second temps"
facultatif: true facultatif: true
JEU:
invite: "Le jeu d'etat (openldap, postgresql, nextcloud... — voir restauration-etat)"
VMID: VMID:
invite: "Identifiant Proxmox de la VM" invite: "Identifiant Proxmox de la VM"
DIALECTE: DIALECTE:
@ -458,6 +460,12 @@ runbooks:
La preuve la plus dure du moteur : detruire un ecosysteme et le refaire depuis le 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. code seul. A ne lancer que sur un chantier — la prod vit ailleurs.
etapes: 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 - cible: raser
nature: destructif nature: destructif
portee: poste portee: poste
@ -472,8 +480,21 @@ runbooks:
duree: "long" duree: "long"
fixes: {CONFIRMER: "true"} fixes: {CONFIRMER: "true"}
pourquoi: >- pourquoi: >-
Refaire tout depuis zero : les VM, puis le deploiement complet. Si le code ne Refaire tout depuis zero : les VM, puis le deploiement complet — ou chaque role
suffit pas, c'est ici qu'on l'apprend. 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 - cible: valider
nature: mesure nature: mesure
pourquoi: "Une reconstruction sans recette n'a rien prouve." pourquoi: "Une reconstruction sans recette n'a rien prouve."

View file

@ -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. 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/<jeu>` ; 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=<id> # 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_dn>`, `base <nom> --vers epreuve`,
`fichiers --vers /var/tmp/epreuve <chemin>`.
> Éprouvé le 2026-08-12 sur Chezlepro. `make valider` rejoue la partie automatisable ; > É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. > la restauration d'une base reste manuelle, et porte un piège décrit plus bas.

View file

@ -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=<hote> JEU=<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=<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'

View file

@ -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. 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 - 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. 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 ## Prérequis
- `serveur_backup` déployé, et sa `serveur_backup_pubkey` correspondant à la clé privée de - `serveur_backup` déployé, et sa `serveur_backup_pubkey` correspondant à la clé privée de

View file

@ -3,6 +3,8 @@
# Chaque nœud déclare ses "jobs" ; restic chiffre côté client + applique la rétention. # Chaque nœud déclare ses "jobs" ; restic chiffre côté client + applique la rétention.
client_backup_paquets: client_backup_paquets:
- restic - restic
# `setops-restaurer` remet les repertoires par `rsync --delete` (absent du gabarit).
- rsync
# Cible (nœud serveur_backup) + transport SSH. # Cible (nœud serveur_backup) + transport SSH.
client_backup_cible: "backup-01.{{ domaine_interne }}" 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 # 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`). # nouvelle, Icinga passe le service au rouge (voir `serveur_icinga_fraicheur_restauration`).
client_backup_ttl_restauration: 691200 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"

View file

@ -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"

View file

@ -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}) }}"

View file

@ -34,60 +34,10 @@
+ (client_backup_jobs | map(attribute='nom') | join(', ')), + (client_backup_jobs | map(attribute='nom') | join(', ')),
'aucun etat non regenerable — aucune sauvegarde installee') }} 'aucun etat non regenerable — aucune sauvegarde installee') }}
- name: Installer restic # L'ACCES AU DEPOT vit a part : les roles proprietaires l'incluent aussi, pour RESTAURER
ansible.builtin.apt: # avant meme que ce role-ci ait tourne (l'AC se restaure dans la couche `pki_racine`).
name: "{{ client_backup_paquets }}" - name: Accès au dépôt (restic, clé, mot de passe, SSH) et outil de restauration
state: present ansible.builtin.include_tasks: acces.yml
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: Assurer le répertoire de préparation des dumps - name: Assurer le répertoire de préparation des dumps
ansible.builtin.file: ansible.builtin.file:

View file

@ -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 '' }}

View file

@ -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 }}/<jeu>. 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 <suivante>` 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=<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

View file

@ -5,6 +5,13 @@ set -euo pipefail
export RESTIC_REPOSITORY="{{ client_backup_repo }}" export RESTIC_REPOSITORY="{{ client_backup_repo }}"
export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass" 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. # 1. Dumps applicatifs (pg_dump, slapcat, forgejo dump...) vers le staging.
{% for job in client_backup_jobs %} {% for job in client_backup_jobs %}
{% if job.commande is defined %} {% if job.commande is defined %}

View file

@ -51,6 +51,41 @@
group: "{{ serveur_dovecot_vmail_utilisateur }}" group: "{{ serveur_dovecot_vmail_utilisateur }}"
mode: "0750" 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) --- # --- Pont de certificat step_ca (déposé par client_pki) ---
- name: TLS — créer le répertoire des certificats - name: TLS — créer le répertoire des certificats
ansible.builtin.file: ansible.builtin.file:

View file

@ -273,6 +273,54 @@
state: link state: link
notify: Redemarrer forgejo 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 - name: Creer les repertoires de donnees
ansible.builtin.file: ansible.builtin.file:
path: "{{ serveur_forgejo_data }}/{{ item }}" path: "{{ serveur_forgejo_data }}/{{ item }}"

View file

@ -27,6 +27,39 @@
serveur_nextcloud_db_port: "{{ resoudre_base_db_port }}" serveur_nextcloud_db_port: "{{ resoudre_base_db_port }}"
no_log: true 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) - name: Paquets (PHP-FPM + extensions, nginx, redis)
ansible.builtin.import_tasks: paquets.yml ansible.builtin.import_tasks: paquets.yml
@ -57,6 +90,9 @@
# --- Sonde de supervision (docs/supervision-conception.md) -------------------------- # --- Sonde de supervision (docs/supervision-conception.md) --------------------------
# Le role qui possede la verite depose sa propre sonde ; le porteur (`client_sante`) la # 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. # 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 - name: Assurer le repertoire des sondes de supervision
ansible.builtin.file: ansible.builtin.file:
path: /usr/local/lib/setops/sondes path: /usr/local/lib/setops/sondes

View file

@ -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

View file

@ -54,6 +54,21 @@
enabled: true enabled: true
state: started 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). # 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 # Placee plus bas, elle arrivait trop tard : « Creer les unites organisationnelles » se
@ -443,6 +458,57 @@
- serveur_openldap_cert_pont.stat.exists | default(false) - serveur_openldap_cert_pont.stat.exists | default(false)
notify: Redemarrer slapd 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) -------------------------- # --- Sonde de supervision (docs/supervision-conception.md) --------------------------
# Le role qui possede la verite depose sa propre sonde ; le porteur (`client_sante`) la # 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. # fait tourner et pousse le verdict, sans savoir ce qu'elle mesure.

View file

@ -72,3 +72,25 @@ serveur_postgresql_exportateur_service: prometheus-postgres-exporter
serveur_postgresql_exportateur_port: 9187 serveur_postgresql_exportateur_port: 9187
serveur_postgresql_exportateur_utilisateur: setops_metriques serveur_postgresql_exportateur_utilisateur: setops_metriques
serveur_postgresql_exportateur_mot_de_passe: "{{ vault_pg_exportateur | default('') }}" 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) }}

View file

@ -216,6 +216,68 @@
label: "{{ item.value.base }}" label: "{{ item.value.base }}"
when: serveur_postgresql_registre | length > 0 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) -------------------------- # --- Sonde de supervision (docs/supervision-conception.md) --------------------------
# Le role qui possede la verite depose sa propre sonde ; le porteur (`client_sante`) la # 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. # fait tourner et pousse le verdict, sans savoir ce qu'elle mesure.

View file

@ -12,6 +12,55 @@
state: started state: started
when: not ansible_check_mode 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) --- # --- Clé DKIM (privée locale ; publique à publier en DNS — Étape B) ---
- name: Créer le répertoire des clés DKIM - name: Créer le répertoire des clés DKIM
ansible.builtin.file: ansible.builtin.file:

View file

@ -76,6 +76,72 @@
create_home: false create_home: false
shell: /usr/sbin/nologin 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 - name: Creer le repertoire STEPPATH
ansible.builtin.file: ansible.builtin.file:
path: "{{ serveur_step_ca_steppath }}" path: "{{ serveur_step_ca_steppath }}"

View file

@ -26,6 +26,40 @@
group: root group: root
mode: "0755" 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). # 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 - name: Creer le repertoire de la racine de la forge du site
ansible.builtin.file: ansible.builtin.file:

View file

@ -7,6 +7,40 @@
update_cache: true update_cache: true
cache_valid_time: 3600 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 - name: Retirer le vhost nginx par defaut de Debian
ansible.builtin.file: ansible.builtin.file:
path: /etc/nginx/sites-enabled/default path: /etc/nginx/sites-enabled/default

View file

@ -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())