diff --git a/CHANGELOG.md b/CHANGELOG.md index d00c339..c404be4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Makefile b/Makefile index aab2f94..dde196c 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/docs/runbooks-construction.yml b/docs/runbooks-construction.yml index 7ed21f2..e81f048 100644 --- a/docs/runbooks-construction.yml +++ b/docs/runbooks-construction.yml @@ -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] diff --git a/playbooks/maintenance/depots_perimes.yml b/playbooks/maintenance/depots_perimes.yml new file mode 100644 index 0000000..9d3593a --- /dev/null +++ b/playbooks/maintenance/depots_perimes.yml @@ -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