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
|
||||
|
||||
## 2026-09-30 (47) — La reconstruction remet l'état : chaque rôle propriétaire restaure le sien
|
||||
|
||||
**Le défaut** (46) : une reconstruction repartait d'un état neuf. Les instantanés se
|
||||
restauraient pour PROUVER qu'ils s'ouvrent, jamais pour être remis en service.
|
||||
|
||||
**Le principe retenu** : pas d'étape à part dans la chaîne — chaque rôle qui POSSÈDE un
|
||||
état le remet **au moment où il le créerait neuf**, et l'ordre vient des couches. La chaîne
|
||||
des runners (`_amorcer-socle` → `deployer-tout`) et `make reconstruire` n'ont pas changé.
|
||||
|
||||
| Rôle | Point de remise |
|
||||
|---|---|
|
||||
| `serveur_step_ca` | avant `step ca init` ; refus si la voûte n'ouvre plus les clés restaurées |
|
||||
| `serveur_postgresql` | bases juste créées, chacune rejouée en UNE transaction |
|
||||
| `serveur_openldap` | fin de rôle (le LDIF exige `ppolicy`), puis comptes de service réalignés sur la voûte |
|
||||
| `serveur_nextcloud` | fin de rôle : base + fichiers + config ensemble, `occ upgrade` |
|
||||
| `serveur_rspamd` | avant la génération DKIM — sinon chaque reconstruction changeait la clé publiée |
|
||||
| `serveur_dovecot`, `serveur_forgejo`, `serveur_web_*` | racine encore vide |
|
||||
|
||||
**Nextcloud se remet en fin de rôle, pas avant l'installation** : le code de `user_oidc` et
|
||||
`richdocuments` n'est pas sauvegardé ; restaurée avant, la base les dirait installés et
|
||||
`occ app:install` refuserait. `serveur_postgresql` exclut donc sa base (et celle
|
||||
d'Icinga, dont l'historique est lié à un environnement non sauvegardé).
|
||||
|
||||
**Quelle incarnation** : le dernier instantané pris AVANT la naissance de la machine — la
|
||||
date de sa clé d'hôte SSH (`machine-id`, lui, vient du gabarit : tous les nœuds portent le
|
||||
2026-09-01). Un état en place n'est jamais écrasé d'office ; ce qui est remplacé est mis de
|
||||
côté (`/var/backups/setops-avant-restauration/`) ; chaque jeu laisse un marqueur.
|
||||
|
||||
**La sauvegarde refuse de déposer tant qu'un état d'avant attend** (code 3). Sans cette
|
||||
garde, `restic forget --keep-daily 7` aurait gardé l'instantané de l'état NEUF et chassé
|
||||
celui d'avant dès qu'ils tombaient le même jour — ce matin, ils étaient à cheval sur minuit
|
||||
par hasard.
|
||||
|
||||
**Nouveaux gestes** : `make sauvegarder-maintenant` (juste avant `raser`, inscrit au
|
||||
runbook `flotte-refaire`), `make restauration-etat`, `make restauration-renoncer`. Outil de
|
||||
nœud utilisable sans Ansible : `setops-restaurer` (avec des répétitions qui ne touchent à
|
||||
rien : `annuaire --essai`, `base --vers`, `fichiers --vers`).
|
||||
|
||||
**Garde** : `scripts/tests/test_restauration.py` — chaque détenteur d'état du catalogue doit
|
||||
avoir son point de remise (une liste qui suit une autre prend du retard), et l'outil, rendu
|
||||
depuis son gabarit avec des doublures de `restic`/`psql`, doit choisir l'incarnation d'avant,
|
||||
refuser d'écraser, bloquer puis libérer la sauvegarde, et couper la section d'une base avant
|
||||
le `DROP DATABASE postgres;`. Le test a trouvé un défaut avant tout déploiement : une
|
||||
apostrophe dans `${c:-…}`, que bash lit comme un guillemet ouvrant.
|
||||
|
||||
**Pas encore éprouvé par une reconstruction réelle** : validé par `ansible-lint` (0 remarque),
|
||||
`--syntax-check`, `make test`. L'épreuve est la prochaine reconstruction.
|
||||
|
||||
## 2026-09-30 (46) — Technolibre : l'état d'avant la reconstruction remis en place, sans reconstruire
|
||||
|
||||
**Le constat** : après (45), le mot de passe de `sysadmin` était revenu à celui de l'amorçage.
|
||||
|
|
|
|||
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_proxmox_fw_externe.py
|
||||
python3 scripts/tests/test_sonde_tcp.py
|
||||
python3 scripts/tests/test_restauration.py
|
||||
|
||||
# LA SONDE QUI RAPPORTE CE QUI DISTINGUE (2026-08-28). Trois faux diagnostics en une
|
||||
# journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine
|
||||
|
|
@ -900,6 +901,30 @@ flux-verifier: ## Verifie que le registre des flux correspond aux meta/flux.yml
|
|||
valider: ansible-runtime ## Passe la recette de validation sur la flotte
|
||||
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/valider.yml
|
||||
|
||||
.PHONY: restauration-etat restauration-renoncer sauvegarder-maintenant
|
||||
sauvegarder-maintenant: ansible-runtime ## Depose l'etat de chaque noeud TOUT DE SUITE (avant de raser) — HOTE=<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
|
||||
schema: ## Regenere docs/audit/schema-plan.json depuis les registres et les validateurs
|
||||
python3 scripts/schema_plan.py
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ variables:
|
|||
DANS:
|
||||
invite: "Jours avant le second temps"
|
||||
facultatif: true
|
||||
JEU:
|
||||
invite: "Le jeu d'etat (openldap, postgresql, nextcloud... — voir restauration-etat)"
|
||||
VMID:
|
||||
invite: "Identifiant Proxmox de la VM"
|
||||
DIALECTE:
|
||||
|
|
@ -458,6 +460,12 @@ runbooks:
|
|||
La preuve la plus dure du moteur : detruire un ecosysteme et le refaire depuis le
|
||||
code seul. A ne lancer que sur un chantier — la prod vit ailleurs.
|
||||
etapes:
|
||||
- cible: sauvegarder-maintenant
|
||||
nature: ecriture
|
||||
pourquoi: >-
|
||||
Deposer l'etat de chaque noeud JUSTE AVANT de raser. La reconstruction remet le
|
||||
dernier instantane anterieur a la naissance des machines : sans ce depot, c'est
|
||||
celui de la nuit, et la journee est perdue.
|
||||
- cible: raser
|
||||
nature: destructif
|
||||
portee: poste
|
||||
|
|
@ -472,8 +480,21 @@ runbooks:
|
|||
duree: "long"
|
||||
fixes: {CONFIRMER: "true"}
|
||||
pourquoi: >-
|
||||
Refaire tout depuis zero : les VM, puis le deploiement complet. Si le code ne
|
||||
suffit pas, c'est ici qu'on l'apprend.
|
||||
Refaire tout depuis zero : les VM, puis le deploiement complet — ou chaque role
|
||||
proprietaire REMET l'etat de l'incarnation precedente (AC, annuaire, bases,
|
||||
Nextcloud, courriel, DKIM). Si le code ne suffit pas, c'est ici qu'on l'apprend.
|
||||
- cible: restauration-etat
|
||||
nature: mesure
|
||||
pourquoi: >-
|
||||
Ce que chaque jeu est devenu : restaure (et depuis quel instantane), neuf, en
|
||||
place. Un jeu EN ATTENTE bloque la sauvegarde de son noeud — c'est voulu.
|
||||
- cible: restauration-renoncer
|
||||
nature: destructif
|
||||
variables: [HOTE, JEU]
|
||||
fixes: {CONFIRMER: "true"}
|
||||
pourquoi: >-
|
||||
Ecarter l'etat d'avant d'un jeu, sans le remettre. La sauvegarde reprend, et
|
||||
l'ancien etat sortira de la retention : une decision, pas une reparation.
|
||||
- cible: valider
|
||||
nature: mesure
|
||||
pourquoi: "Une reconstruction sans recette n'a rien prouve."
|
||||
|
|
|
|||
|
|
@ -112,6 +112,49 @@ Note : ne s'applique qu'aux Forgejo **gérées par Set-OPS**.
|
|||
|
||||
## 5. Restaurer — et d'abord : prouver qu'on peut
|
||||
|
||||
### 5.0 La reconstruction remet l'état (depuis le 2026-09-30)
|
||||
|
||||
Jusqu'au 2026-09-30, **une reconstruction repartait d'un état neuf** : nouvelle racine
|
||||
d'AC, annuaire vierge (`sysadmin` au mot de passe d'amorçage), bases, Nextcloud et courriel
|
||||
vides. Les instantanés se restauraient pour *prouver* qu'ils s'ouvrent, jamais pour être
|
||||
remis en service.
|
||||
|
||||
Désormais **chaque rôle propriétaire remet l'état de l'incarnation précédente**, au moment
|
||||
où il le créerait neuf — la bonne séquence vient des couches de déploiement :
|
||||
|
||||
| Rôle | Quand | Condition « vierge » |
|
||||
|---|---|---|
|
||||
| `serveur_step_ca` | avant `step ca init` | pas de `ca.json` — et la voûte doit ouvrir les clés restaurées |
|
||||
| `serveur_postgresql` | juste après la création des bases | base sans table (hors `nextcloud`, `icinga`) |
|
||||
| `serveur_openldap` | en fin de rôle (schéma et `ppolicy` chargés) | aucune entrée hors la racine |
|
||||
| `serveur_dovecot` | après le répertoire des boîtes | répertoire vide |
|
||||
| `serveur_rspamd` | avant la génération DKIM | pas de clé DKIM |
|
||||
| `serveur_forgejo` | avant l'arborescence de données | répertoire vide |
|
||||
| `serveur_nextcloud` | **en fin de rôle** : base + fichiers + config ensemble, puis `occ upgrade` | pas de `config.php` au départ, base vide |
|
||||
| `serveur_web_frontal` / `_dorsal` | après leur racine | racine vide |
|
||||
|
||||
**L'instantané remis** est le dernier pris **avant la naissance de la machine** (date de
|
||||
sa clé d'hôte SSH). Un état en place n'est jamais écrasé d'office. Chaque jeu laisse un
|
||||
marqueur dans `/etc/setops/restauration/<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 ;
|
||||
> 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.
|
||||
- Un dépôt neuf est **vide jusqu'à la première exécution** : après une reconstruction
|
||||
from-zero, déclencher une première sauvegarde plutôt que d'attendre 02:30.
|
||||
- Restauration : `restic restore` avec le même mot de passe (cf. `docs/runbooks-exploitation.md`).
|
||||
- Restauration : **faite par la reconstruction elle-même** depuis le 2026-09-30 — chaque rôle
|
||||
propriétaire inclut `tasks/restaurer.yml` et remet l'état de l'incarnation précédente ;
|
||||
l'outil de nœud est `setops-restaurer` (cf. `docs/runbooks-exploitation.md` §5.0).
|
||||
|
||||
## Prérequis
|
||||
- `serveur_backup` déployé, et sa `serveur_backup_pubkey` correspondant à la clé privée de
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
# Chaque nœud déclare ses "jobs" ; restic chiffre côté client + applique la rétention.
|
||||
client_backup_paquets:
|
||||
- restic
|
||||
# `setops-restaurer` remet les repertoires par `rsync --delete` (absent du gabarit).
|
||||
- rsync
|
||||
|
||||
# Cible (nœud serveur_backup) + transport SSH.
|
||||
client_backup_cible: "backup-01.{{ domaine_interne }}"
|
||||
|
|
@ -128,3 +130,22 @@ client_backup_restauration_part: "10%"
|
|||
# Huit jours : une semaine entre deux controles, plus une journee de marge. Au-dela sans
|
||||
# nouvelle, Icinga passe le service au rouge (voir `serveur_icinga_fraicheur_restauration`).
|
||||
client_backup_ttl_restauration: 691200
|
||||
|
||||
# --- LA RESTAURATION DANS LA RECONSTRUCTION (2026-09-30) ----------------------------
|
||||
#
|
||||
# Jusqu'ici une reconstruction repartait d'un etat NEUF : les instantanes se restauraient
|
||||
# pour PROUVER qu'ils s'ouvrent, jamais pour etre remis en service. Desormais chaque role
|
||||
# proprietaire d'un etat inclut `tasks/restaurer.yml` au moment ou il le creerait neuf, et
|
||||
# remet celui de l'incarnation precedente s'il existe. Voir `templates/restaurer.sh.j2`.
|
||||
#
|
||||
# `false` : ne jamais restaurer (chaque jeu est alors acte « desactive », et la
|
||||
# sauvegarde ne s'en trouve pas bloquee).
|
||||
client_backup_restauration_active: true
|
||||
# Imposer un instantane (son `short_id`) au lieu du dernier anterieur a la naissance.
|
||||
# Par hote, et le temps d'une passe : `-e client_backup_restauration_instantane=abcd1234`.
|
||||
client_backup_restauration_instantane: ""
|
||||
# Ou chaque jeu laisse la trace de ce qui lui est arrive (restaure, neuf, en_place...).
|
||||
client_backup_restauration_marques: "/etc/setops/restauration"
|
||||
# Ce qui etait en place avant d'etre ecrase. Hors des chemins sauvegardes : on ne veut
|
||||
# pas deposer chez l'hebergeur l'etat qu'on vient justement de remplacer.
|
||||
client_backup_restauration_mise_de_cote: "/var/backups/setops-avant-restauration"
|
||||
|
|
|
|||
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(', ')),
|
||||
'aucun etat non regenerable — aucune sauvegarde installee') }}
|
||||
|
||||
- name: Installer restic
|
||||
ansible.builtin.apt:
|
||||
name: "{{ client_backup_paquets }}"
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Créer le répertoire des secrets Set-OPS
|
||||
ansible.builtin.file:
|
||||
path: /etc/setops
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
|
||||
- name: Déposer la clé SSH privée de sauvegarde
|
||||
ansible.builtin.copy:
|
||||
content: "{{ client_backup_ssh_privkey }}"
|
||||
dest: "{{ client_backup_ssh_key }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
no_log: true
|
||||
|
||||
- name: Déposer le mot de passe restic
|
||||
ansible.builtin.copy:
|
||||
content: "{{ client_backup_password }}\n"
|
||||
dest: /etc/setops/restic.pass
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
no_log: true
|
||||
|
||||
- name: Assurer /root/.ssh
|
||||
ansible.builtin.file:
|
||||
path: /root/.ssh
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
|
||||
- name: Configurer l'accès SSH à la cible de sauvegarde
|
||||
ansible.builtin.blockinfile:
|
||||
path: /root/.ssh/config
|
||||
create: true
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
marker: "# {mark} SETOPS BACKUP {{ client_backup_cible }}"
|
||||
block: |
|
||||
Host {{ client_backup_cible }}
|
||||
User {{ client_backup_utilisateur_distant }}
|
||||
IdentityFile {{ client_backup_ssh_key }}
|
||||
IdentitiesOnly yes
|
||||
StrictHostKeyChecking accept-new
|
||||
# L'ACCES AU DEPOT vit a part : les roles proprietaires l'incluent aussi, pour RESTAURER
|
||||
# avant meme que ce role-ci ait tourne (l'AC se restaure dans la couche `pki_racine`).
|
||||
- name: Accès au dépôt (restic, clé, mot de passe, SSH) et outil de restauration
|
||||
ansible.builtin.include_tasks: acces.yml
|
||||
|
||||
- name: Assurer le répertoire de préparation des dumps
|
||||
ansible.builtin.file:
|
||||
|
|
|
|||
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_PASSWORD_FILE="/etc/setops/restic.pass"
|
||||
|
||||
# 0. UN ETAT D'AVANT ATTEND-IL ? (2026-09-30)
|
||||
# Sur une machine reconstruite, tant que l'etat de l'incarnation precedente n'a ete ni
|
||||
# remis ni ecarte, deposer serait NUISIBLE : `restic forget --keep-daily` garde un
|
||||
# instantane par jour, et celui de l'etat neuf chasserait celui d'avant, s'ils tombent le
|
||||
# meme jour. On refuse donc, bruyamment (code 3). Voir `setops-restaurer garde`.
|
||||
/usr/local/sbin/setops-restaurer garde
|
||||
|
||||
# 1. Dumps applicatifs (pg_dump, slapcat, forgejo dump...) vers le staging.
|
||||
{% for job in client_backup_jobs %}
|
||||
{% if job.commande is defined %}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,41 @@
|
|||
group: "{{ serveur_dovecot_vmail_utilisateur }}"
|
||||
mode: "0750"
|
||||
|
||||
# LES BOITES D'UNE INCARNATION PRECEDENTE (2026-09-30). Remises tant que le repertoire est
|
||||
# vide — avant que la premiere livraison n'y cree quoi que ce soit.
|
||||
- name: Restauration — les boites sont-elles vierges ?
|
||||
ansible.builtin.find:
|
||||
paths: "{{ serveur_dovecot_vmail_base }}"
|
||||
file_type: any
|
||||
register: serveur_dovecot_vmail_initial
|
||||
|
||||
- name: Restauration — serveur_dovecot d'une incarnation precedente ?
|
||||
ansible.builtin.include_role:
|
||||
name: client_backup
|
||||
tasks_from: restaurer.yml
|
||||
vars:
|
||||
client_backup_restaurer_jeu: serveur_dovecot
|
||||
client_backup_restaurer_vierge: "{{ serveur_dovecot_vmail_initial.matched | default(1) == 0 }}"
|
||||
|
||||
- name: Restauration — remettre serveur_dovecot
|
||||
changed_when: true
|
||||
ansible.builtin.command:
|
||||
argv: >-
|
||||
{{ ['/usr/local/sbin/setops-restaurer', 'fichiers',
|
||||
'--instantane', client_backup_restauration.instantane]
|
||||
+ ['--proprietaire', serveur_dovecot_vmail_utilisateur + ':' + serveur_dovecot_vmail_utilisateur]
|
||||
+ client_backup_restauration.chemins }}
|
||||
when: client_backup_restauration.etat == 'a_restaurer'
|
||||
|
||||
- name: Restauration — acter serveur_dovecot
|
||||
ansible.builtin.include_role:
|
||||
name: client_backup
|
||||
tasks_from: acter.yml
|
||||
vars:
|
||||
client_backup_acter_etat: restaure
|
||||
when: client_backup_restauration.etat == 'a_restaurer'
|
||||
|
||||
|
||||
# --- Pont de certificat step_ca (déposé par client_pki) ---
|
||||
- name: TLS — créer le répertoire des certificats
|
||||
ansible.builtin.file:
|
||||
|
|
|
|||
|
|
@ -273,6 +273,54 @@
|
|||
state: link
|
||||
notify: Redemarrer forgejo
|
||||
|
||||
# LES DEPOTS D'UNE INCARNATION PRECEDENTE (2026-09-30). Avant que le role ne cree
|
||||
# l'arborescence : ensuite, le repertoire ne serait plus vierge. La base, elle, a deja
|
||||
# ete remise par `serveur_postgresql` (ou vit dans ces fichiers, en SQLite). La
|
||||
# configuration revient avec le secret JWT que le role relit plus bas.
|
||||
- name: Restauration — les donnees de Forgejo sont-elles vierges ?
|
||||
ansible.builtin.find:
|
||||
paths: "{{ serveur_forgejo_data }}"
|
||||
file_type: any
|
||||
register: serveur_forgejo_data_initial
|
||||
|
||||
- name: Restauration — serveur_forgejo d'une incarnation precedente ?
|
||||
ansible.builtin.include_role:
|
||||
name: client_backup
|
||||
tasks_from: restaurer.yml
|
||||
vars:
|
||||
client_backup_restaurer_jeu: serveur_forgejo
|
||||
client_backup_restaurer_vierge: "{{ serveur_forgejo_data_initial.matched | default(1) == 0 }}"
|
||||
|
||||
- name: Restauration — remettre serveur_forgejo
|
||||
changed_when: true
|
||||
ansible.builtin.command:
|
||||
argv: >-
|
||||
{{ ['/usr/local/sbin/setops-restaurer', 'fichiers',
|
||||
'--instantane', client_backup_restauration.instantane]
|
||||
+ client_backup_restauration.chemins }}
|
||||
when: client_backup_restauration.etat == 'a_restaurer'
|
||||
|
||||
# restic rend les proprietaires NUMERIQUES ; `git` n'a pas forcement le meme uid que sur
|
||||
# la machine d'avant.
|
||||
- name: Restauration — rendre les donnees au service
|
||||
ansible.builtin.file:
|
||||
path: "{{ item.chemin }}"
|
||||
owner: "{{ item.proprietaire }}"
|
||||
group: "{{ serveur_forgejo_utilisateur }}"
|
||||
recurse: true
|
||||
loop:
|
||||
- { chemin: "{{ serveur_forgejo_data }}", proprietaire: "{{ serveur_forgejo_utilisateur }}" }
|
||||
- { chemin: "{{ serveur_forgejo_config_dir }}", proprietaire: root }
|
||||
when: client_backup_restauration.etat == 'a_restaurer'
|
||||
|
||||
- name: Restauration — acter serveur_forgejo
|
||||
ansible.builtin.include_role:
|
||||
name: client_backup
|
||||
tasks_from: acter.yml
|
||||
vars:
|
||||
client_backup_acter_etat: restaure
|
||||
when: client_backup_restauration.etat == 'a_restaurer'
|
||||
|
||||
- name: Creer les repertoires de donnees
|
||||
ansible.builtin.file:
|
||||
path: "{{ serveur_forgejo_data }}/{{ item }}"
|
||||
|
|
|
|||
|
|
@ -27,6 +27,39 @@
|
|||
serveur_nextcloud_db_port: "{{ resoudre_base_db_port }}"
|
||||
no_log: true
|
||||
|
||||
# ETAT DE DEPART, RELEVE AVANT L'INSTALLATION : apres, la configuration existerait et la
|
||||
# base aurait ses tables — on ne saurait plus si l'on part de rien. Voir `restaurer.yml`.
|
||||
- name: Restauration — la configuration existe-t-elle deja ?
|
||||
ansible.builtin.stat:
|
||||
path: "{{ serveur_nextcloud_racine }}/config/config.php"
|
||||
register: serveur_nextcloud_config_initiale
|
||||
|
||||
- name: Restauration — le serveur de base de Nextcloud
|
||||
ansible.builtin.set_fact:
|
||||
serveur_nextcloud_hote_bd: "{{ (serveurs_bd | default({}))[resoudre_base_entree.serveur].hote }}"
|
||||
serveur_nextcloud_nom_bd: "{{ resoudre_base_entree.base | default(serveur_nextcloud_db_name) }}"
|
||||
|
||||
- name: Restauration — la base de Nextcloud a-t-elle deja des tables ?
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- runuser
|
||||
- -u
|
||||
- postgres
|
||||
- --
|
||||
- psql
|
||||
- -tAX
|
||||
- -d
|
||||
- "{{ serveur_nextcloud_nom_bd }}"
|
||||
- -c
|
||||
- >-
|
||||
select count(*) from information_schema.tables
|
||||
where table_schema not in ('pg_catalog','information_schema')
|
||||
delegate_to: "{{ serveur_nextcloud_hote_bd }}"
|
||||
register: serveur_nextcloud_tables_initiales
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
failed_when: false
|
||||
|
||||
- name: Paquets (PHP-FPM + extensions, nginx, redis)
|
||||
ansible.builtin.import_tasks: paquets.yml
|
||||
|
||||
|
|
@ -57,6 +90,9 @@
|
|||
# --- Sonde de supervision (docs/supervision-conception.md) --------------------------
|
||||
# Le role qui possede la verite depose sa propre sonde ; le porteur (`client_sante`) la
|
||||
# fait tourner et pousse le verdict, sans savoir ce qu'elle mesure.
|
||||
- name: Etat d'une incarnation precedente (base, fichiers, configuration)
|
||||
ansible.builtin.import_tasks: restaurer.yml
|
||||
|
||||
- name: Assurer le repertoire des sondes de supervision
|
||||
ansible.builtin.file:
|
||||
path: /usr/local/lib/setops/sondes
|
||||
|
|
|
|||
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
|
||||
state: started
|
||||
|
||||
# L'ANNUAIRE EST-IL VIERGE ? Mesure AVANT que ce role n'y cree quoi que ce soit (unites,
|
||||
# comptes de service) : apres, il ne le serait plus jamais. Le paquet ne pose que la
|
||||
# racine — `cn=admin` n'y figure que sur les installations anciennes.
|
||||
- name: Restauration — l'annuaire est-il vierge ?
|
||||
ansible.builtin.shell: >-
|
||||
set -o pipefail;
|
||||
slapcat -b '{{ serveur_openldap_base_dn }}' 2>/dev/null | grep '^dn:'
|
||||
| grep -v -i -x -e 'dn: {{ serveur_openldap_base_dn }}' -e 'dn: cn=admin,{{ serveur_openldap_base_dn }}'
|
||||
| wc -l
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: serveur_openldap_entrees_initiales
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
|
||||
# LA ROTATION PASSE AVANT TOUT CE QUI SE LIE EN ADMINISTRATEUR (2026-09-13).
|
||||
#
|
||||
# Placee plus bas, elle arrivait trop tard : « Creer les unites organisationnelles » se
|
||||
|
|
@ -443,6 +458,57 @@
|
|||
- serveur_openldap_cert_pont.stat.exists | default(false)
|
||||
notify: Redemarrer slapd
|
||||
|
||||
# --- L'ANNUAIRE D'UNE INCARNATION PRECEDENTE (2026-09-30) ---------------------------
|
||||
#
|
||||
# EN FIN DE ROLE, PAS AU DEBUT. Le LDIF porte des attributs de `ppolicy` (`pwdChangedTime`,
|
||||
# `pwdReset`...) que slapd refuse tant que l'overlay n'est pas charge — c'est-a-dire plus
|
||||
# haut dans ce role. On remplace donc ce que le role vient de creer par ce qui vivait
|
||||
# avant ; `setops-restaurer` le rejoue d'abord a blanc, et met la base neuve de cote.
|
||||
#
|
||||
# Sans ceci, une reconstruction rendait a `sysadmin` le mot de passe d'amorcage : c'est
|
||||
# ainsi que l'absence de restauration a ete decouverte, le 2026-09-30.
|
||||
- name: Restauration — l'annuaire d'une incarnation precedente ?
|
||||
ansible.builtin.include_role:
|
||||
name: client_backup
|
||||
tasks_from: restaurer.yml
|
||||
vars:
|
||||
client_backup_restaurer_jeu: serveur_openldap
|
||||
client_backup_restaurer_vierge: "{{ (serveur_openldap_entrees_initiales.stdout | default('1') | int) == 0 }}"
|
||||
|
||||
- name: Restauration — remettre l'annuaire
|
||||
changed_when: true
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/local/sbin/setops-restaurer
|
||||
- annuaire
|
||||
- --instantane
|
||||
- "{{ client_backup_restauration.instantane }}"
|
||||
- "{{ serveur_openldap_base_dn }}"
|
||||
when: client_backup_restauration.etat == 'a_restaurer'
|
||||
|
||||
# Les comptes de service reviennent avec le mot de passe de l'EPOQUE. On les realigne sur
|
||||
# la voute tout de suite : Keycloak, Postfix et Dovecot s'y lient avec la valeur actuelle.
|
||||
- name: Restauration — realigner les comptes de service sur la voute
|
||||
community.general.ldap_passwd:
|
||||
dn: "cn={{ item.cn }},{{ serveur_openldap_services_dn }}"
|
||||
passwd: "{{ lookup('vars', item.secret) }}"
|
||||
server_uri: "ldapi:///"
|
||||
bind_dn: "{{ serveur_openldap_admin_dn }}"
|
||||
bind_pw: "{{ serveur_openldap_admin_password }}"
|
||||
loop: "{{ serveur_openldap_comptes_service }}"
|
||||
loop_control:
|
||||
label: "{{ item.cn }}"
|
||||
no_log: true
|
||||
when: client_backup_restauration.etat == 'a_restaurer'
|
||||
|
||||
- name: Restauration — acter l'annuaire remis
|
||||
ansible.builtin.include_role:
|
||||
name: client_backup
|
||||
tasks_from: acter.yml
|
||||
vars:
|
||||
client_backup_acter_etat: restaure
|
||||
when: client_backup_restauration.etat == 'a_restaurer'
|
||||
|
||||
# --- Sonde de supervision (docs/supervision-conception.md) --------------------------
|
||||
# Le role qui possede la verite depose sa propre sonde ; le porteur (`client_sante`) la
|
||||
# fait tourner et pousse le verdict, sans savoir ce qu'elle mesure.
|
||||
|
|
|
|||
|
|
@ -72,3 +72,25 @@ serveur_postgresql_exportateur_service: prometheus-postgres-exporter
|
|||
serveur_postgresql_exportateur_port: 9187
|
||||
serveur_postgresql_exportateur_utilisateur: setops_metriques
|
||||
serveur_postgresql_exportateur_mot_de_passe: "{{ vault_pg_exportateur | default('') }}"
|
||||
|
||||
# --- RESTAURATION (2026-09-30) — voir tasks/main.yml ---------------------------------
|
||||
#
|
||||
# Les bases que CE role ne rejoue pas, designees par leur `consommateur` au registre :
|
||||
# - nextcloud : il se remet LUI-MEME, en fin de son role. Sa base, ses fichiers et sa
|
||||
# configuration vont ensemble, et le code de ses applications (user_oidc,
|
||||
# richdocuments) n'est pas sauvegarde : restauree avant son installation, la base
|
||||
# les dirait installees sans qu'elles le soient, et `occ app:install` refuserait ;
|
||||
# - icinga : l'historique de supervision, lie a l'environnement d'Icinga, que rien ne
|
||||
# sauvegarde. Il repart de zero, comme le reste de la supervision.
|
||||
serveur_postgresql_restauration_consommateurs_exclus:
|
||||
- nextcloud
|
||||
- icinga
|
||||
|
||||
serveur_postgresql_bases_restaurables: >-
|
||||
{{ ((serveur_postgresql_registre | default([]))
|
||||
| rejectattr('value.consommateur', 'defined') | map(attribute='value.base') | list)
|
||||
+ ((serveur_postgresql_registre | default([]))
|
||||
| selectattr('value.consommateur', 'defined')
|
||||
| rejectattr('value.consommateur', 'in', serveur_postgresql_restauration_consommateurs_exclus)
|
||||
| map(attribute='value.base') | list)
|
||||
+ (serveur_postgresql_bases | map(attribute='nom') | list) }}
|
||||
|
|
|
|||
|
|
@ -216,6 +216,68 @@
|
|||
label: "{{ item.value.base }}"
|
||||
when: serveur_postgresql_registre | length > 0
|
||||
|
||||
# --- LES BASES D'UNE INCARNATION PRECEDENTE (2026-09-30) ----------------------------
|
||||
#
|
||||
# ICI, et pas plus tard : les bases viennent d'etre creees, leurs consommateurs (Keycloak,
|
||||
# Forgejo...) ne les ont pas encore touchees. Chaque base VIDE recoit sa section du
|
||||
# `pg_dumpall` d'avant, en une transaction ; une base qui a deja des tables n'est jamais
|
||||
# ecrasee d'office. Les roles et leurs mots de passe restent ceux de la voute : la section
|
||||
# d'une base ne les porte pas.
|
||||
- name: Restauration — quelles bases sont vierges ?
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- psql
|
||||
- -tAX
|
||||
- -d
|
||||
- "{{ item }}"
|
||||
- -c
|
||||
- >-
|
||||
select count(*) from information_schema.tables
|
||||
where table_schema not in ('pg_catalog','information_schema')
|
||||
become: true
|
||||
become_user: postgres
|
||||
loop: "{{ serveur_postgresql_bases_restaurables }}"
|
||||
register: serveur_postgresql_tables_initiales
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
failed_when: false
|
||||
|
||||
- name: Restauration — retenir les bases vierges
|
||||
ansible.builtin.set_fact:
|
||||
serveur_postgresql_bases_vierges: >-
|
||||
{{ serveur_postgresql_tables_initiales.results
|
||||
| selectattr('rc', 'equalto', 0)
|
||||
| selectattr('stdout', 'equalto', '0')
|
||||
| map(attribute='item') | list }}
|
||||
|
||||
- name: Restauration — les bases d'une incarnation precedente ?
|
||||
ansible.builtin.include_role:
|
||||
name: client_backup
|
||||
tasks_from: restaurer.yml
|
||||
vars:
|
||||
client_backup_restaurer_jeu: serveur_postgresql
|
||||
client_backup_restaurer_vierge: "{{ serveur_postgresql_bases_vierges | length > 0 }}"
|
||||
|
||||
- name: Restauration — rejouer chaque base vierge
|
||||
changed_when: true
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/local/sbin/setops-restaurer
|
||||
- base
|
||||
- --instantane
|
||||
- "{{ client_backup_restauration.instantane }}"
|
||||
- "{{ item }}"
|
||||
loop: "{{ serveur_postgresql_bases_vierges }}"
|
||||
when: client_backup_restauration.etat == 'a_restaurer'
|
||||
|
||||
- name: Restauration — acter les bases remises
|
||||
ansible.builtin.include_role:
|
||||
name: client_backup
|
||||
tasks_from: acter.yml
|
||||
vars:
|
||||
client_backup_acter_etat: restaure
|
||||
when: client_backup_restauration.etat == 'a_restaurer'
|
||||
|
||||
# --- Sonde de supervision (docs/supervision-conception.md) --------------------------
|
||||
# Le role qui possede la verite depose sa propre sonde ; le porteur (`client_sante`) la
|
||||
# fait tourner et pousse le verdict, sans savoir ce qu'elle mesure.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,55 @@
|
|||
state: started
|
||||
when: not ansible_check_mode
|
||||
|
||||
# CE QUE RSPAMD A APPRIS, ET SA CLE DKIM (2026-09-30).
|
||||
#
|
||||
# La cle DKIM se genere « seulement si absente » : chaque reconstruction en fabriquait
|
||||
# donc une NEUVE, et l'enregistrement DNS publie cessait de correspondre — le courriel
|
||||
# signe devenait invalide chez les destinataires, sans rien casser chez nous. On la
|
||||
# remet AVANT la generation. Le paquet a deja peuple `/var/lib/rspamd` : on le remplace
|
||||
# (mis de cote), rspamd arrete.
|
||||
- name: Restauration — la cle DKIM existe-t-elle deja ?
|
||||
ansible.builtin.stat:
|
||||
path: "{{ serveur_rspamd_dkim_repertoire }}/{{ serveur_rspamd_dkim_domaine }}.{{ serveur_rspamd_dkim_selecteur }}.key"
|
||||
register: serveur_rspamd_dkim_initiale
|
||||
|
||||
- name: Restauration — rspamd d'une incarnation precedente ?
|
||||
ansible.builtin.include_role:
|
||||
name: client_backup
|
||||
tasks_from: restaurer.yml
|
||||
vars:
|
||||
client_backup_restaurer_jeu: serveur_rspamd
|
||||
client_backup_restaurer_vierge: "{{ not serveur_rspamd_dkim_initiale.stat.exists }}"
|
||||
|
||||
- name: Restauration — remettre rspamd
|
||||
when: client_backup_restauration.etat == 'a_restaurer'
|
||||
block:
|
||||
- name: Restauration — arreter rspamd
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ serveur_rspamd_service }}"
|
||||
state: stopped
|
||||
|
||||
- name: Restauration — remettre son etat et sa cle
|
||||
changed_when: true
|
||||
ansible.builtin.command:
|
||||
argv: >-
|
||||
{{ ['/usr/local/sbin/setops-restaurer', 'fichiers', '--remplacer',
|
||||
'--instantane', client_backup_restauration.instantane,
|
||||
'--proprietaire', '_rspamd:_rspamd']
|
||||
+ client_backup_restauration.chemins }}
|
||||
|
||||
- name: Restauration — relancer rspamd
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ serveur_rspamd_service }}"
|
||||
state: started
|
||||
|
||||
- name: Restauration — acter rspamd
|
||||
ansible.builtin.include_role:
|
||||
name: client_backup
|
||||
tasks_from: acter.yml
|
||||
vars:
|
||||
client_backup_acter_etat: restaure
|
||||
|
||||
# --- Clé DKIM (privée locale ; publique à publier en DNS — Étape B) ---
|
||||
- name: Créer le répertoire des clés DKIM
|
||||
ansible.builtin.file:
|
||||
|
|
|
|||
|
|
@ -76,6 +76,72 @@
|
|||
create_home: false
|
||||
shell: /usr/sbin/nologin
|
||||
|
||||
# L'AC NE RENAIT PAS SI ELLE A DEJA VECU (2026-09-30).
|
||||
#
|
||||
# `step ca init` fabriquait une racine neuve a chaque reconstruction : toute confiance
|
||||
# accordee a l'ancienne (postes, navigateurs, autres ecosystemes) tombait sans bruit.
|
||||
# Technolibre a change de racine le 2026-09-30, Chezlepro le 2026-09-13.
|
||||
#
|
||||
# C'est ICI, avant l'init, que l'AC d'une incarnation precedente doit revenir : ensuite,
|
||||
# toute la flotte s'enrole aupres d'elle comme d'habitude. Plus tard, il faudrait
|
||||
# reenroler chaque machine.
|
||||
- name: L'autorite a-t-elle deja ete initialisee ?
|
||||
ansible.builtin.stat:
|
||||
path: "{{ serveur_step_ca_steppath }}/config/ca.json"
|
||||
register: serveur_step_ca_ca_json
|
||||
|
||||
- name: Restauration — l'autorite d'une incarnation precedente ?
|
||||
ansible.builtin.include_role:
|
||||
name: client_backup
|
||||
tasks_from: restaurer.yml
|
||||
vars:
|
||||
client_backup_restaurer_jeu: serveur_step_ca
|
||||
client_backup_restaurer_vierge: "{{ not serveur_step_ca_ca_json.stat.exists }}"
|
||||
|
||||
- name: Restauration — remettre l'autorite (racine, intermediaire, base des emissions)
|
||||
changed_when: true
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/local/sbin/setops-restaurer
|
||||
- fichiers
|
||||
- --instantane
|
||||
- "{{ client_backup_restauration.instantane }}"
|
||||
- --proprietaire
|
||||
- "{{ serveur_step_ca_utilisateur }}:{{ serveur_step_ca_utilisateur }}"
|
||||
- "{{ serveur_step_ca_steppath }}"
|
||||
when: client_backup_restauration.etat == 'a_restaurer'
|
||||
|
||||
# LES CLES SONT CHIFFREES PAR LE MOT DE PASSE DE L'EPOQUE. La tache suivante reecrit
|
||||
# `password.txt` depuis la voute : si la voute a change depuis, step-ca ne demarrerait
|
||||
# plus, et l'erreur parlerait de dechiffrement, pas de restauration.
|
||||
- name: Restauration — lire les mots de passe restaures
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ serveur_step_ca_steppath }}/{{ item }}"
|
||||
loop: [password.txt, provisioner_password.txt]
|
||||
register: serveur_step_ca_mdp_restaures
|
||||
no_log: true
|
||||
when: client_backup_restauration.etat == 'a_restaurer'
|
||||
|
||||
- name: Restauration — la voute ouvre-t-elle encore ces cles ?
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- (serveur_step_ca_mdp_restaures.results[0].content | b64decode) == serveur_step_ca_password
|
||||
- (serveur_step_ca_mdp_restaures.results[1].content | b64decode) == serveur_step_ca_provisioner_password
|
||||
fail_msg: >-
|
||||
L'AC restauree ({{ client_backup_restauration.instantane }}) est chiffree par un mot
|
||||
de passe que la voute ne porte plus. Remettre les anciennes valeurs de
|
||||
vault_step_ca_password / provisioner dans la voute, ou renoncer a cette AC.
|
||||
no_log: true
|
||||
when: client_backup_restauration.etat == 'a_restaurer'
|
||||
|
||||
- name: Restauration — acter l'autorite remise
|
||||
ansible.builtin.include_role:
|
||||
name: client_backup
|
||||
tasks_from: acter.yml
|
||||
vars:
|
||||
client_backup_acter_etat: restaure
|
||||
when: client_backup_restauration.etat == 'a_restaurer'
|
||||
|
||||
- name: Creer le repertoire STEPPATH
|
||||
ansible.builtin.file:
|
||||
path: "{{ serveur_step_ca_steppath }}"
|
||||
|
|
|
|||
|
|
@ -26,6 +26,40 @@
|
|||
group: root
|
||||
mode: "0755"
|
||||
|
||||
# CE QUE LE DORSAL PORTAIT (2026-09-30). Les sites clones depuis la forge se refont
|
||||
# seuls ; ce qui n'y vit pas revient d'ici, tant que la racine est vide.
|
||||
- name: Restauration — la racine des webapps est-elle vierge ?
|
||||
ansible.builtin.find:
|
||||
paths: "{{ serveur_web_dorsal_racine }}"
|
||||
file_type: any
|
||||
register: serveur_web_dorsal_racine_initiale
|
||||
|
||||
- name: Restauration — serveur_web_dorsal d'une incarnation precedente ?
|
||||
ansible.builtin.include_role:
|
||||
name: client_backup
|
||||
tasks_from: restaurer.yml
|
||||
vars:
|
||||
client_backup_restaurer_jeu: serveur_web_dorsal
|
||||
client_backup_restaurer_vierge: "{{ serveur_web_dorsal_racine_initiale.matched | default(1) == 0 }}"
|
||||
|
||||
- name: Restauration — remettre serveur_web_dorsal
|
||||
changed_when: true
|
||||
ansible.builtin.command:
|
||||
argv: >-
|
||||
{{ ['/usr/local/sbin/setops-restaurer', 'fichiers',
|
||||
'--instantane', client_backup_restauration.instantane]
|
||||
+ client_backup_restauration.chemins }}
|
||||
when: client_backup_restauration.etat == 'a_restaurer'
|
||||
|
||||
- name: Restauration — acter serveur_web_dorsal
|
||||
ansible.builtin.include_role:
|
||||
name: client_backup
|
||||
tasks_from: acter.yml
|
||||
vars:
|
||||
client_backup_acter_etat: restaure
|
||||
when: client_backup_restauration.etat == 'a_restaurer'
|
||||
|
||||
|
||||
# LA CONFIANCE AVANT LE CLONE, LIMITEE A LA FORGE DU SITE (voir defaults/main.yml).
|
||||
- name: Creer le repertoire de la racine de la forge du site
|
||||
ansible.builtin.file:
|
||||
|
|
|
|||
|
|
@ -7,6 +7,40 @@
|
|||
update_cache: true
|
||||
cache_valid_time: 3600
|
||||
|
||||
# CE QUE LE CATALOGUE DE SAUVEGARDE DIT DU FRONTAL (2026-09-30). Il ne sert plus rien
|
||||
# lui-meme — il relaie —, mais le catalogue emporte encore `/srv/web` : tant qu'il
|
||||
# l'emporte, on le remet, quand la racine est vide.
|
||||
- name: Restauration — la racine du frontal est-elle vierge ?
|
||||
ansible.builtin.find:
|
||||
paths: "{{ serveur_web_frontal_racine_base | default('/srv/web') }}"
|
||||
file_type: any
|
||||
register: serveur_web_frontal_racine_initiale
|
||||
|
||||
- name: Restauration — serveur_web_frontal d'une incarnation precedente ?
|
||||
ansible.builtin.include_role:
|
||||
name: client_backup
|
||||
tasks_from: restaurer.yml
|
||||
vars:
|
||||
client_backup_restaurer_jeu: serveur_web_frontal
|
||||
client_backup_restaurer_vierge: "{{ serveur_web_frontal_racine_initiale.matched | default(1) == 0 }}"
|
||||
|
||||
- name: Restauration — remettre serveur_web_frontal
|
||||
changed_when: true
|
||||
ansible.builtin.command:
|
||||
argv: >-
|
||||
{{ ['/usr/local/sbin/setops-restaurer', 'fichiers',
|
||||
'--instantane', client_backup_restauration.instantane]
|
||||
+ client_backup_restauration.chemins }}
|
||||
when: client_backup_restauration.etat == 'a_restaurer'
|
||||
|
||||
- name: Restauration — acter serveur_web_frontal
|
||||
ansible.builtin.include_role:
|
||||
name: client_backup
|
||||
tasks_from: acter.yml
|
||||
vars:
|
||||
client_backup_acter_etat: restaure
|
||||
when: client_backup_restauration.etat == 'a_restaurer'
|
||||
|
||||
- name: Retirer le vhost nginx par defaut de Debian
|
||||
ansible.builtin.file:
|
||||
path: /etc/nginx/sites-enabled/default
|
||||
|
|
|
|||
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