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:
parent
d3437a6308
commit
8a9da0c31a
25 changed files with 1544 additions and 57 deletions
48
CHANGELOG.md
48
CHANGELOG.md
|
|
@ -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.
|
||||||
|
|
|
||||||
25
Makefile
25
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_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
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
64
playbooks/backup/restauration.yml
Normal file
64
playbooks/backup/restauration.yml
Normal 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'
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
68
roles/client_backup/tasks/acces.yml
Normal file
68
roles/client_backup/tasks/acces.yml
Normal 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"
|
||||||
31
roles/client_backup/tasks/acter.yml
Normal file
31
roles/client_backup/tasks/acter.yml
Normal 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}) }}"
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
98
roles/client_backup/tasks/restaurer.yml
Normal file
98
roles/client_backup/tasks/restaurer.yml
Normal 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 '' }}
|
||||||
315
roles/client_backup/templates/restaurer.sh.j2
Normal file
315
roles/client_backup/templates/restaurer.sh.j2
Normal 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
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 }}"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
129
roles/serveur_nextcloud/tasks/restaurer.yml
Normal file
129
roles/serveur_nextcloud/tasks/restaurer.yml
Normal 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
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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) }}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 }}"
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
213
scripts/tests/test_restauration.py
Normal file
213
scripts/tests/test_restauration.py
Normal 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())
|
||||||
Loading…
Reference in a new issue