depots perimes : ce qui n'existe qu'ici ne se detruit pas depuis ici

serveur_ops clone ce que le plan declare et ne retire rien : le runner de
Chezlepro-locataire portait encore le clone de SITE-Chezlepro, la carte de son hebergeur,
longtemps apres que son plan ait cesse de la declarer.

Le retrait n'entre pas dans le role. Un role qui efface des dossiers a chaque passage est
une grenade degoupillee : une faute de frappe dans serveur_ops_depots suffirait a perdre du
travail local. C'est donc un geste separe, qui regarde par defaut et n'efface que sur
CONFIRMER=true. Trois choses ne sont jamais retirees, meme confirmees : ce qui n'est pas un
depot git, ce qui porte des modifications non validees, ce qui porte des commits qu'aucun
distant ne porte.

La garde a servi au premier essai : le releve a nomme SITE-Chezlepro et venv, et venv a ete
ecarte parce que ce n'est pas un depot git. Une version naive aurait efface l'environnement
Python du runner en se disant satisfaite.

Passe deux fois sur le runner reel : regarder (changed=0), puis confirmer (changed=1) avec
relecture. Le runner ne porte plus que Set-OPS-public et OPS-Chezlepro ; sa console rend
portee=tenant, materialiser=False, fabric=False — le pouvoir de LIRE la fabric est tombe
avec la carte, et c'est juste.

Valide : syntax-check du playbook, runbooks.py verifier a 0 ecart (la cible neuve est portee
par le runbook Filiation), make test a 0 echec. P02 reste en echec pour la raison anterieure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-20 20:18:59 -04:00
parent 4188cf5b6f
commit 7ef7ca196a
4 changed files with 212 additions and 0 deletions

View file

@ -1,5 +1,53 @@
# CHANGELOG — Set-OPS
## 2026-09-20 (9) — Ce qui n'existe qu'ici ne se détruit pas depuis ici
**83 preuves, `make test` à 0 échec, 133 cibles documentées.** Le runner de
Chezlepro-locataire portait encore le clone de `SITE-Chezlepro` — la carte de son
hébergeur — longtemps après que son plan ait cessé de la déclarer. `serveur_ops` clone ce
qui est déclaré ; il ne retirait rien.
### Pourquoi ce retrait n'est pas dans le rôle
Un rôle qui efface des dossiers à chaque passage est une grenade dégoupillée : une faute
de frappe dans `serveur_ops_depots` suffirait à perdre du travail local. Le retrait est
donc un geste **séparé**, qui **regarde** par défaut et n'efface que sur `CONFIRMER=true`
— comme `raser` et `site-raser`.
`make depots-perimes` nomme ce qu'un runner porte et que son plan ne déclare plus, puis
dit ce qu'une confirmation emporterait. **Trois choses ne sont jamais retirées**, même
confirmées : ce qui n'est pas un dépôt git, ce qui porte des modifications non validées,
et ce qui porte des commits qu'aucun distant ne porte.
### La garde a servi au premier essai
Le relevé a nommé **deux** dossiers non déclarés : `SITE-Chezlepro`, et `venv` —
l'environnement Python du runner. `venv` a été écarté parce que ce n'est pas un dépôt
git. **Une version naïve de ce geste aurait effacé l'environnement d'exécution du
runner**, et l'aurait fait en se disant satisfaite.
C'est la raison d'être de la première des trois règles : un dossier qu'on ne sait pas
lire n'est pas un dossier périmé, c'est un dossier qu'on ne comprend pas. Le doute se
résout en s'abstenant, pas en effaçant.
### Écrire, puis relire — ici : regarder, puis vérifier
Le geste relit après avoir écrit : un `state: absent` qui se dit satisfait sans que le
dossier ait disparu est exactement le genre de succès qui ment. Mesure après coup — le
runner du locataire ne porte plus que `Set-OPS-public` et `OPS-Chezlepro`, ce que son plan
déclare, et sa console rend `portee=tenant`, `configurer=True`, `materialiser=False`,
`fabric=False` : même le pouvoir de LIRE la fabric est tombé avec la carte, ce qui est
juste — il n'y a plus de miroir à consulter.
### Ce qui a été vérifié
`--syntax-check` sur le playbook, `python3 scripts/runbooks.py verifier` (0 écart,
la cible neuve est portée par le runbook « Filiation »), `make test` à 0 échec. Le geste
a été passé **deux fois** sur le runner réel : une fois pour regarder — rien touché,
`changed=0` — puis une fois confirmé, `changed=1`, avec sa relecture.
**P02 reste en échec**, pour la raison antérieure consignée à l'entrée `(6)`.
## 2026-09-20 (8) — Un pouvoir se mesure sur ce qu'on peut ouvrir, pas sur ce qu'on peut lire
**83 preuves, `make test` à 0 échec (21 tests neufs aujourd'hui).** La vue Assistants a

View file

@ -1255,6 +1255,16 @@ genome-pousser: ## Le runner du site pousse le genome sur la forge du site — D
SETOPS_DEPOT="$(DEPOT)" ansible-playbook -i scripts/site_inventaire.py \
playbooks/maintenance/genome_pousser.yml "$${supp[@]}" $(ARGS)
.PHONY: depots-perimes
depots-perimes: ansible-runtime _instance-requise ## Depots qu'un runner ne declare plus : regarde, et retire avec CONFIRMER=true
@# DESTRUCTIF SUR CONFIRMATION SEULEMENT. Sans `CONFIRMER=true`, ce geste REGARDE :
@# il nomme ce que le runner porte et que son plan ne declare plus, et dit ce qu'un
@# retrait emporterait. Trois choses ne sont jamais retirees, meme confirmees : ce
@# qui n'est pas un depot git, ce qui porte des modifications non validees, et ce
@# qui porte des commits qu'aucun distant ne porte.
CONFIRMER="$(CONFIRMER)" ansible-playbook -i $(INVENTAIRE_PRODUCTION) \
playbooks/maintenance/depots_perimes.yml $(if $(HOTE),--limit $(HOTE),) $(ARGS)
.PHONY: site-creer
forge-amorcer: ## Amorce la forge d'un SITE neuf avec le genome — CONFIRMER=true
@# LE MAILLON QUI N'EXISTAIT PAS (2026-09-12, premiere reconstruction du site depuis

View file

@ -632,6 +632,14 @@ runbooks:
pourquoi: >-
Le SITE amorce le runner d'un locataire, SANS ses secrets. Trois murs connus :
apt, pip, et le genome lui-meme.
- cible: depots-perimes
nature: destructif
fixes: {CONFIRMER: "true"}
pourquoi: >-
Un depot raye du plan reste sur le disque du runner, qui garde de quoi lire un
ecosysteme qu'il ne declare plus. Trois choses ne sont jamais retirees : ce qui
n'est pas un depot git, ce qui porte des modifications non validees, et ce qui
porte des commits qu'aucun distant ne porte.
- cible: emancipation-prouver
nature: mesure
variables: [SERVICE, HOTE]

View file

@ -0,0 +1,146 @@
---
# LES DEPOTS D'UN RUNNER SUIVENT SA DECLARATION — DANS LES DEUX SENS.
#
# `serveur_ops` clone ce que `serveur_ops_depots` declare. Il ne retire rien : un depot
# raye du plan reste sur le disque, et le runner garde de quoi lire un ecosysteme qu'il
# ne declare plus. Mesure du 2026-09-20 : le runner de Chezlepro-LOCATAIRE portait encore
# le clone de `SITE-Chezlepro` — la carte de son hebergeur — apres que son plan ait cesse
# de la declarer.
#
# POURQUOI CE N'EST PAS DANS LE ROLE. Retirer un depot est DESTRUCTIF, et la regle du
# depot est qu'une action destructive s'annonce et se confirme. Un role qui efface des
# dossiers a chaque passage serait une grenade degoupillee : il suffirait d'une faute de
# frappe dans `serveur_ops_depots` pour perdre un travail local. On en fait donc un geste
# separe, qui REGARDE par defaut et n'efface que sur `CONFIRMER=true`.
#
# CE QUI NE SERA JAMAIS EFFACE, meme avec la confirmation :
# - un dossier qui n'est pas un depot git (on ne sait pas ce que c'est) ;
# - un depot qui porte des modifications non validees ;
# - un depot qui porte des commits qu'aucun distant ne porte.
# Dans ces trois cas le playbook le DIT et passe. Ce qui n'existe qu'ici ne se detruit
# pas depuis ici.
#
# make depots-perimes regarde, n'efface rien
# make depots-perimes CONFIRMER=true retire ceux qui ne portent rien d'unique
- name: Les dépôts qu'un runner ne déclare plus
hosts: "{{ cible | default('serveur_ops') }}"
become: true
become_user: "{{ serveur_ops_utilisateur | default('setops') }}"
gather_facts: false
vars:
racine: "{{ serveur_ops_racine | default('/opt/setops') }}"
confirme: "{{ (lookup('env', 'CONFIRMER') | default('', true)) == 'true' }}"
tasks:
- name: Exiger de savoir ce que ce runner déclare
ansible.builtin.assert:
that:
- (serveur_ops_depots | default([])) | length > 0
fail_msg: >-
Cet hôte ne déclare aucun dépôt (`serveur_ops_depots`). Sans la liste de ce
qu'il DOIT porter, on ne peut pas dire ce qui est périmé — et surtout pas
l'effacer.
- name: Lister ce que le runner porte
ansible.builtin.find:
paths: "{{ racine }}"
file_type: directory
recurse: false
hidden: false
register: presents
- name: Retenir ceux que le plan ne déclare plus
ansible.builtin.set_fact:
perimes: >-
{{ presents.files | map(attribute='path') | map('basename') | list
| difference(serveur_ops_depots | map(attribute='dest') | list) }}
- name: Le dire quand il n'y a rien à retirer
ansible.builtin.debug:
msg: >-
Ce runner ne porte que ce qu'il déclare
({{ serveur_ops_depots | map(attribute='dest') | join(', ') }}).
when: perimes | length == 0
# ÉCRIRE, PUIS RELIRE — ici : REGARDER avant de détruire. Trois questions par dépôt, et
# la réponse décide. `git status` et `git log` ne modifient rien.
- name: Regarder ce que chaque dépôt périmé porte d'unique
ansible.builtin.shell:
cmd: |
set -u
d={{ (racine ~ '/' ~ item) | quote }}
if [ ! -d "$d/.git" ]; then echo '{"git": false}'; exit 0; fi
sale=$(git -C "$d" status --porcelain 2>/dev/null | head -c 2000)
# Des commits qu'AUCUN distant ne porte : ce qui n'existe qu'ici.
seuls=$(git -C "$d" log --branches --not --remotes --oneline 2>/dev/null | head -c 2000)
printf '{"git": true, "sale": %s, "seuls": %s}\n' \
"$([ -n "$sale" ] && echo true || echo false)" \
"$([ -n "$seuls" ] && echo true || echo false)"
executable: /bin/bash
loop: "{{ perimes }}"
register: examen
changed_when: false
- name: Nommer ce qui est retenu, et pourquoi
ansible.builtin.debug:
msg: >-
{{ item.item }} : {{
'ce n_est pas un dépôt git — on ne sait pas ce que c_est, on n_y touche pas'
if not (item.stdout | from_json).git else
'des modifications non validées vivent ici' if (item.stdout | from_json).sale else
'des commits qu_aucun distant ne porte vivent ici' if (item.stdout | from_json).seuls else
'ne porte rien d_unique — retirable'
}}
loop: "{{ examen.results }}"
loop_control:
label: "{{ item.item }}"
- name: Retenir ceux qu'on peut retirer sans rien perdre
ansible.builtin.set_fact:
retirables: >-
{{ examen.results
| selectattr('stdout')
| selectattr('stdout', 'search', '"git": true')
| rejectattr('stdout', 'search', '"sale": true')
| rejectattr('stdout', 'search', '"seuls": true')
| map(attribute='item') | list }}
- name: Dire ce qu'une confirmation retirerait
ansible.builtin.debug:
msg: >-
{{ retirables | length }} dépôt(s) retirable(s) : {{ retirables | join(', ') }}.
Rien n'a été touché — relancer avec CONFIRMER=true pour les retirer.
when:
- retirables | length > 0
- not (confirme | bool)
- name: Retirer les dépôts que ce runner ne déclare plus
ansible.builtin.file:
path: "{{ racine }}/{{ item }}"
state: absent
loop: "{{ retirables }}"
when:
- confirme | bool
- not ansible_check_mode
# RELIRE APRÈS AVOIR ÉCRIT. Un `state: absent` qui se dit satisfait sans que le
# dossier ait disparu est exactement le genre de succès qui ment.
- name: Vérifier qu'ils ont bien disparu
ansible.builtin.stat:
path: "{{ racine }}/{{ item }}"
loop: "{{ retirables }}"
register: apres
when: confirme | bool
- name: Refuser un retrait qui n'a pas eu lieu
ansible.builtin.assert:
that:
- not (item.stat.exists | default(false))
fail_msg: "{{ item.item }} est toujours là après le retrait."
success_msg: "{{ item.item }} : retiré."
loop: "{{ apres.results | default([]) }}"
loop_control:
label: "{{ item.item | default('') }}"
when: confirme | bool