site : le runner travaille, et le genome remonte chez lui
Some checks are pending
verifier / verifier (push) Waiting to run

`serveur_ops` et `serveur_ops_site` sont deployes sur `site-ops-01` : le moteur,
les plans des trois tenants, les collections hors ligne, et la voute de
l'underlay deposee CHIFFREE. Le site a son runner.

DEUX FORMES DE RUNNER, ET UNE SEULE ETAIT PREVUE.

`serveur_ops` exigeait un depot `role: instance` et un `serveur_ops_instance` —
la forme d'un runner de TENANT, qui pilote un ecosysteme, monte son plan par le
lien `instance`, detient sa voute.

Un runner de SITE ne pilote rien : il MATERIALISE le terrain que d'autres
occuperont, et son inventaire est dynamique. Son plan declarait donc ses depots
de tenants en `role: tenant`, a dessein et avec ses raisons ecrites — et le role
le refusait au nom d'une exigence qui ne le concernait pas. Il exige desormais
que le runner pilote QUELQUE CHOSE, sans prescrire quoi.

Et il posait le lien quand meme : `serveur_ops_instance: ""` donnait
`instance -> /opt/setops/`, un repertoire qui n'est l'ecosysteme de personne. Un
lien qui existe et ne designe rien est pire qu'un lien absent : tout ce qui le
suit croit avoir trouve une instance.

LE CERTIFICAT COUVRE LES NOMS DU SERVICE.

`client_pki` ne mettait dans ses SAN que le FQDN, le nom court et l'IP. Le
clonage du genome a bute dessus : « certificate subject name
(site-forge-01.genese.internal) does not match target hostname
'forge.genese.internal' ». Le nom declare `expose:` etait publie partout —
plancher, zone DNS — et couvert nulle part.

Les expositions que cet hote SERT REELLEMENT entrent maintenant dans le
certificat. Un certificat qui revendiquerait le nom d'un service rendu ailleurs
serait une usurpation, pas une commodite.

`make genome-pousser` — LE RUNNER POUSSE, LE POSTE NE ROUTE PAS.

La forge du genome vit DANS le site, sur un reseau que le poste de l'exploitant
ne route pas. On ne perce pas un chemin pour lui : on lui retire le role. Le
poste emballe les commits dans un `git bundle` — un fichier, verifiable, qui ne
demande aucun reseau — et le runner verifie, avance EN AVANCE RAPIDE SEULEMENT,
puis pousse avec SA cle, autorisee en ecriture sur ces depots-la seulement.

Ce qui est pousse se DERIVE de `serveur_ops_depots`. `DEPOT=<nom>` en cible un
seul.

Trois pieges rencontres, tous inscrits dans le code : l'outil ne peut pas etre
supposé present sur le runner (il ne recevrait cette version qu'APRES la poussee
qu'elle sert a faire — le controleur le porte) ; la branche n'est pas toujours
`main` (deux depots vivent sur `master`, et le plan le declarait deja) ; le
dossier des depots freres contient un espace, d'ou `argv` et non `cmd`.

Le juge n'est pas le journal du playbook mais LA FORGE : on demande a son API ce
qu'elle porte, et on refuse si ca differe de ce que porte le runner. Six depots
verifies.

Pourquoi ca comptait : la forge du site etait restee quatre commits derriere le
poste, sans que rien ne le signale — dont le correctif qui desarme le pare-feu
Proxmox. Un ecosysteme qui se reproduit depuis une forge en retard reproduit ses
defauts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-26 08:52:37 -04:00
parent e5841ea684
commit 6a5a49f924
7 changed files with 642 additions and 16 deletions

View file

@ -1,5 +1,64 @@
# CHANGELOG — Set-OPS
## 2026-08-26 — Le runner du site travaille, et le génome remonte chez lui
**46 preuves.** `serveur_ops` et `serveur_ops_site` sont déployés sur `site-ops-01` : le
moteur, les plans des trois tenants, les collections hors ligne, et la voûte de l'underlay
déposée **chiffrée**. Le site a son runner.
### Deux formes de runner, et une seule était prévue
`serveur_ops` exigeait un dépôt `role: instance` et un `serveur_ops_instance`. C'est la
forme d'un runner de **tenant** : il pilote un écosystème, monte son plan par le lien
`instance`, détient sa voûte.
Un runner de **site** ne pilote rien — il **matérialise** le terrain que d'autres
occuperont, et son inventaire est dynamique. Son plan déclarait donc ses dépôts de tenants
en `role: tenant`, à dessein et avec ses raisons écrites, et le rôle le refusait au nom
d'une exigence qui ne le concernait pas. Il exige désormais que le runner pilote
*quelque chose*, sans prescrire quoi : un `instance`, ou un `hebergeur`.
Et il posait le lien quand même : `serveur_ops_instance: ""` donnait `instance ->
/opt/setops/`, un répertoire qui n'est l'écosystème de personne. *Un lien qui existe et ne
désigne rien est pire qu'un lien absent : tout ce qui le suit croit avoir trouvé une
instance.*
### Le certificat couvre les noms du service, pas seulement ceux de la machine
`client_pki` ne mettait dans ses SAN que le FQDN, le nom court et l'IP. Le clonage du
génome a buté dessus : *« certificate subject name (site-forge-01.genese.internal) does not
match target hostname 'forge.genese.internal' »*. Le nom déclaré `expose:` était publié
partout — plancher, zone DNS — et couvert nulle part.
Les expositions **que cet hôte sert réellement** entrent maintenant dans le certificat.
Un certificat qui revendiquerait le nom d'un service rendu ailleurs serait une usurpation,
pas une commodité.
### `make genome-pousser` — le runner pousse, le poste ne route pas
La forge du génome vit **dans** le site, sur un réseau que le poste de l'exploitant ne
route pas. On ne perce pas un chemin pour lui : on lui retire le rôle. Le poste emballe les
commits dans un `git bundle` — un fichier, vérifiable, qui ne demande aucun réseau — et le
runner vérifie, avance **en avance rapide seulement**, puis pousse avec **sa** clé.
Ce qui est poussé se **dérive** de `serveur_ops_depots` : les dépôts que le runner porte et
dont une copie existe à côté du moteur. Six, ici. `DEPOT=<nom>` en cible un seul.
Trois pièges rencontrés, tous inscrits dans le code :
- l'outil ne peut pas être supposé présent sur le runner — il ne recevrait cette version
qu'**après** la poussée qu'elle sert à faire. Le contrôleur le porte ;
- la branche n'est pas toujours `main` — deux dépôts vivent sur `master`, et le plan le
déclarait déjà ;
- le dossier des dépôts frères contient un espace : `argv` et non `cmd`.
Le juge n'est pas le journal du playbook mais **la forge** : on lui demande son API ce
qu'elle porte, et on refuse si ça diffère de ce que porte le runner.
*Pourquoi ça comptait : la forge du site était restée quatre commits derrière le poste,
sans que rien ne le signale — dont le correctif qui désarme le pare-feu Proxmox. Un
écosystème qui se reproduit depuis une forge en retard reproduit ses défauts.*
## 2026-08-25 — Quatre zones inverses, et rien de plus
**47 preuves.** Le site ne servait aucun PTR. `serveur_powerdns_zone_inverse` dérivait

View file

@ -749,6 +749,31 @@ site-appliquer: ## Applique un role aux machines du site — GROUPE=<serveur_ops
ansible-playbook -i scripts/site_inventaire.py "playbooks/groupes/$(GROUPE).yml" \
"$${supp[@]}" $(ARGS)
# --- LE GENOME REMONTE SUR LA FORGE DU SITE -----------------------------------
#
# La forge du genome vit DANS le site, sur un reseau que le poste de l'exploitant ne route
# pas — et c'est voulu : le poste n'a de patte que sur l'administration, la frontiere
# police le reste. Il ne peut donc pas pousser lui-meme.
#
# On ne perce pas un chemin pour lui. Le RUNNER est dans le site, il porte le genome, il a
# une identite : on lui PORTE les commits dans un `git bundle` — un fichier, verifiable,
# qui ne demande aucun reseau — et c'est LUI qui pousse, avec SA cle.
#
# Mesure du 2026-08-26 : la forge du site etait restee quatre commits derriere le poste,
# sans que rien ne le signale. Le runner clonait donc un moteur perime — dont, ce jour-la,
# le correctif qui desarme le pare-feu Proxmox. Un ecosysteme qui se reproduit depuis une
# forge en retard reproduit ses defauts.
#
# CE QUI EST POUSSE SE DERIVE de `serveur_ops_depots` (au plan du site) : on pousse ceux
# dont une copie locale existe a cote du moteur, et rien d'autre.
.PHONY: genome-pousser
genome-pousser: ## Le runner du site pousse le genome sur la forge du site — DEPOT=<nom> pour un seul
@set -e; \
voute="$$(dirname "$$(readlink -f underlay.yml)")/underlay.vault.yml"; \
supp=(); [[ -f "$$voute" ]] && supp+=( -e "@$$voute" ); \
SETOPS_DEPOT="$(DEPOT)" ansible-playbook -i scripts/site_inventaire.py \
playbooks/maintenance/genome_pousser.yml "$${supp[@]}" $(ARGS)
.PHONY: site-creer
site-creer: ## Cree les machines du site depuis l'underlay — CONFIRMER=true
@set -e; \

View file

@ -0,0 +1,83 @@
# Preuve de conformite — Set-OPS — 2026-08-26
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
> [`docs/audit/README.md`](README.md), et le registre trace :
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/production/hosts.yml`
- **Verdict** : ✅ CONFORME (46 OK · 0 echec · 1 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  |
| P02 | Tests unitaires (inventory_host) | — | ✅ OK | >>> le verrou tient : aucune VM n'aurait ete touchee |
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0 : plan et inventaire applique coincident. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check). |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 36 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 34 rôles, 92 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_oauth2_proxy |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ⚪ SAUTE | Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026). |
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 18 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 14 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 3 instance(s) federee(s), aucun index en collision. |
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 11 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 50 groupe(s), 79 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 5 hote(s) x 5 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 28 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 17, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 50 scripts expliques et atteignables, 103 cibles make documentees, 60 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 14 exigence(s) de role, toutes satisfaites (39 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (16 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (23 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 0 application(s) exigeant une base l'ont toutes (0 entree(s) au registre). |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 2 hote(s) detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). |
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 35 role(s) serveur/client tous nommes, 36 groupe(s) cite(s) en table existent tous. |
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 27 page(s) de wiki toutes atteignables. |
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 46 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 5 machine(s) du plan retrouvees, 56 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 integration(s) appliquent leur serveur avant leurs clients. |
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
## Couverture des affirmations ✅ du registre
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
elles restent hors du harnais recurrent (rien d'executable a rejouer).
## Declarations d'intention (⚪ invérifiables localement — assumees)
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
comme declarations d'intention, non comme preuves :
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
contre une flotte vivante.
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
_Rapport genere le 2026-08-26._

View file

@ -0,0 +1,263 @@
---
# LE RUNNER DU SITE POUSSE LE GENOME SUR LA FORGE DU SITE.
#
# POURQUOI CE DETOUR, ET POURQUOI IL EST BON (2026-08-26).
#
# La forge du genome vit DANS le site, sur un reseau que le poste de l'exploitant ne route
# pas — et c'est voulu : le poste n'a de patte que sur l'administration, la frontiere
# police le reste. Il ne peut donc pas pousser lui-meme.
#
# On ne perce pas un chemin pour lui. Le RUNNER existe exactement pour ca : il est dans le
# site, il a le genome, il a une identite. On lui PORTE les commits dans un `git bundle` —
# un fichier, verifiable, qui ne demande aucun reseau — et c'est LUI qui pousse.
#
# Mesure du 2026-08-26 : la forge du site etait restee quatre commits en arriere du poste,
# sans que rien ne le signale. Le runner clonait donc un moteur perime — dont, ce jour-la,
# le correctif qui desarme le pare-feu Proxmox. Un ecosysteme qui se reproduit depuis une
# forge en retard reproduit ses defauts.
#
# CE QUI EST POUSSE SE DERIVE, il ne s'ecrit pas : `serveur_ops_depots` (au plan du site)
# dit ce que le runner porte. On pousse ceux dont une copie locale existe a cote du
# moteur, et rien d'autre.
#
# make genome-pousser tous les depots portes par le runner
# make genome-pousser DEPOT=<nom> un seul (le nom du depot sur la forge)
#
# L'ECRITURE S'AUTHENTIFIE, LA LECTURE NON. `serveur_ops` clone en anonyme, par doctrine.
# Pousser est un autre acte : le runner s'y presente avec SA cle — celle que `serveur_ops`
# lui a generee — declaree en cle de depot, en ecriture, sur ces depots-la seulement.
# `url` reste en HTTPS anonyme ; seul `pushurl` passe par SSH.
- name: Le runner du site pousse le genome sur la forge du site
hosts: serveur_ops_site
become: true
become_user: "{{ serveur_ops_utilisateur | default('setops') }}"
gather_facts: false
vars:
genome_racine: "{{ serveur_ops_racine | default('/opt/setops') }}"
genome_depot_demande: "{{ lookup('env', 'SETOPS_DEPOT') | default('', true) }}"
# Le dossier qui contient le moteur ET ses depots freres, sur le CONTROLEUR.
genome_freres: "{{ playbook_dir | dirname | dirname | dirname }}"
# Le dossier du moteur, tel que le plan le nomme — jamais ecrit en dur.
genome_moteur: "{{ (serveur_ops_depots | selectattr('role', 'eq', 'moteur') | first).dest }}"
tasks:
- name: Exiger de savoir quoi pousser
ansible.builtin.assert:
that:
- (serveur_ops_depots | default([])) | length > 0
fail_msg: >-
Ce poste ne déclare aucun dépôt (`serveur_ops_depots`). Rien à pousser — et
probablement rien à piloter non plus : voir le plan de cette machine.
# Ce que le runner porte, restreint a ce dont une copie existe sur le controleur.
# Un depot declare mais absent d'ici n'est pas une faute : il vit ailleurs.
- name: Voir lesquels de ces dépôts existent sur le contrôleur
ansible.builtin.stat:
path: "{{ genome_freres }}/{{ item.dest }}/.git"
delegate_to: localhost
become: false
loop: "{{ serveur_ops_depots }}"
loop_control:
label: "{{ item.dest }}"
register: genome_locaux
- name: Retenir ceux qu'on peut effectivement porter
ansible.builtin.set_fact:
genome_a_pousser: >-
{{ genome_locaux.results
| selectattr('stat.exists')
| map(attribute='item') | list
| selectattr('depot', 'equalto', genome_depot_demande)
| list
if genome_depot_demande | length > 0
else genome_locaux.results | selectattr('stat.exists')
| map(attribute='item') | list }}
- name: Dire ce qui va être poussé
ansible.builtin.debug:
msg: >-
{{ genome_a_pousser | map(attribute='depot') | list
| ternary(genome_a_pousser | map(attribute='depot') | list,
'aucun dépôt à pousser') }}
- name: Refuser un DEPOT= qui ne désigne rien
ansible.builtin.assert:
that: genome_a_pousser | length > 0
fail_msg: >-
Aucun dépôt à pousser. {{ 'Le dépôt `' ~ genome_depot_demande ~ '` n est pas
porté par ce runner, ou n a pas de copie locale à côté du moteur.'
if genome_depot_demande | length > 0 else
'Aucun des dépôts déclarés n a de copie locale à côté du moteur.' }}
# LA CLE DU RUNNER DOIT ETRE AUTORISEE SUR CE QU'IL POUSSE — sinon la forge refuse,
# depot par depot, et l'exploitant doit aller cliquer. La cible s'en charge : la
# declaration est idempotente (422 = deja la), et sa portee est exactement ce qu'on
# pousse, jamais plus large.
- name: Lire la clé publique du runner
ansible.builtin.slurp:
src: "{{ genome_racine }}/.ssh/id_{{ serveur_ops_cle_type | default('ed25519') }}.pub"
register: genome_cle
- name: Autoriser cette clé en écriture sur les dépôts poussés
ansible.builtin.uri:
url: >-
https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation
| default('genome') }}/{{ item.depot }}/keys
method: POST
url_username: forgejo-admin
url_password: "{{ vault_forgejo_admin }}"
force_basic_auth: true
validate_certs: false
body_format: json
body:
title: "runner du site — {{ inventory_hostname }}"
key: "{{ genome_cle.content | b64decode | trim }}"
read_only: false
status_code: [201, 422]
loop: "{{ genome_a_pousser }}"
loop_control:
label: "{{ item.depot }}"
register: genome_cles
changed_when: false
no_log: true
- name: Un endroit pour fabriquer les colis, sur le contrôleur
ansible.builtin.tempfile:
state: directory
suffix: -genome
delegate_to: localhost
become: false
register: genome_atelier
- name: Un endroit pour déposer les colis
ansible.builtin.file:
path: "{{ genome_racine }}/.bundles"
state: directory
owner: "{{ serveur_ops_utilisateur | default('setops') }}"
group: "{{ serveur_ops_utilisateur | default('setops') }}"
mode: "0700"
# LA LOGIQUE GIT VIT DANS `scripts/genome_colis.py`, comme partout ici : le playbook
# orchestre, le script fait. Il voyage AVEC le moteur — le runner en a donc une copie
# dès qu'il l'a cloné, et rien de spécial n'est déposé sur la machine.
- name: Emballer chaque dépôt sur le contrôleur
# `argv` ET NON `cmd` : le dossier des dépôts frères peut contenir des espaces —
# c'est le cas ici (« Espace Chezlepro »). Une chaîne `cmd` est découpée sur les
# blancs, et python recevait « /home/…/Espace » comme nom de fichier.
ansible.builtin.command:
argv:
- python3
- "{{ genome_freres }}/{{ genome_moteur }}/scripts/genome_colis.py"
- emballer
- "{{ genome_freres }}/{{ item.dest }}"
- "{{ genome_atelier.path }}/{{ item.depot }}.bundle"
- --branche
- "{{ item.branche | default('main') }}"
delegate_to: localhost
become: false
loop: "{{ genome_a_pousser }}"
loop_control:
label: "{{ item.dest }}"
changed_when: true
- name: Porter les colis jusqu'au runner
ansible.builtin.copy:
src: "{{ genome_atelier.path }}/{{ item.depot }}.bundle"
dest: "{{ genome_racine }}/.bundles/{{ item.depot }}.bundle"
owner: "{{ serveur_ops_utilisateur | default('setops') }}"
group: "{{ serveur_ops_utilisateur | default('setops') }}"
mode: "0600"
loop: "{{ genome_a_pousser }}"
loop_control:
label: "{{ item.depot }}"
# L'OUTIL VOYAGE AVEC LE COLIS, il n'est pas supposé déjà là (2026-08-26).
#
# Première version : on lançait `scripts/genome_colis.py` depuis le moteur cloné sur
# le runner. Circulaire — le runner ne reçoit cette version du script qu'APRÈS la
# poussée qu'elle sert à faire. Et ça se reproduirait à chaque évolution de l'outil :
# le runner en retard ne saurait jamais rattraper son retard.
#
# C'est le contrôleur qui détient la version qui fait autorité. Il la porte.
- name: Porter l'outil lui-même jusqu'au runner
ansible.builtin.copy:
src: "{{ genome_freres }}/{{ genome_moteur }}/scripts/genome_colis.py"
dest: "{{ genome_racine }}/.bundles/genome_colis.py"
owner: "{{ serveur_ops_utilisateur | default('setops') }}"
group: "{{ serveur_ops_utilisateur | default('setops') }}"
mode: "0700"
- name: Le runner vérifie, avance en avance rapide, et pousse
ansible.builtin.command:
argv:
- python3
- "{{ genome_racine }}/.bundles/genome_colis.py"
- recevoir
- "{{ genome_racine }}/{{ item.dest }}"
- "{{ genome_racine }}/.bundles/{{ item.depot }}.bundle"
- "git@forge.{{ domaine_interne }}:{{ serveur_ops_forge_organisation | default('genome') }}/{{ item.depot }}.git"
- --cle
- "{{ genome_racine }}/.ssh/id_{{ serveur_ops_cle_type | default('ed25519') }}"
- --branche
- "{{ item.branche | default('main') }}"
loop: "{{ genome_a_pousser }}"
loop_control:
label: "{{ item.depot }}"
register: genome_recu
# `changed_when: false` ET C'EST VOULU. Dans une boucle, ni le registre (pas encore
# defini) ni `stdout` nu ne sont lisibles ici — les deux ont ete essayes, les deux
# ont leve. Plutot qu'une condition qui ment ou qui casse, on ne pretend rien : ce
# qui a change est dit par le VERDICT, plus bas, qui compare la forge au runner et
# nomme la transition. La verite vient de la forge, pas d'un drapeau de tache.
changed_when: false
- name: Demander à la forge ce qu'elle porte maintenant
# On interroge son API plutôt que `git ls-remote` : c'est la forge elle-même qui
# répond, et la lecture est anonyme — aucun justificatif n'entre ici.
ansible.builtin.uri:
url: >-
https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation
| default('genome') }}/{{ item.depot }}/branches/{{ item.branche | default('main') }}
validate_certs: false
status_code: [200]
loop: "{{ genome_a_pousser }}"
loop_control:
label: "{{ item.depot }}"
register: genome_forge
changed_when: false
- name: La forge porte-t-elle exactement ce que le runner porte ?
ansible.builtin.assert:
that:
- genome_forge.results[idx].json.commit.id
== (genome_recu.results[idx].stdout | from_json).apres
fail_msg: >-
{{ item.depot }} : la forge porte
{{ genome_forge.results[idx].json.commit.id[:8] | default('rien') }} alors que
le runner porte
{{ (genome_recu.results[idx].stdout | from_json).apres[:8] }}. La poussée n'a pas
abouti — ne pas cloner depuis cette forge tant que ce n'est pas réglé.
success_msg: >-
{{ item.depot }} :
{{ (genome_recu.results[idx].stdout | from_json).avant[:7] }} →
{{ (genome_recu.results[idx].stdout | from_json).apres[:7] }}, et la forge le porte.
loop: "{{ genome_a_pousser }}"
loop_control:
label: "{{ item.depot }}"
index_var: idx
# Le genome n'a pas a trainer en double sur la machine : ce qui compte est dans les
# depots et sur la forge.
- name: Retirer les colis
ansible.builtin.file:
path: "{{ genome_racine }}/.bundles"
state: absent
- name: Retirer l'atelier du contrôleur
ansible.builtin.file:
path: "{{ genome_atelier.path }}"
state: absent
delegate_to: localhost
become: false

View file

@ -28,12 +28,32 @@ client_pki_nom_cert: "{{ ansible_fqdn | default(ansible_hostname) }}"
client_pki_cert: "{{ client_pki_steppath }}/certs/{{ client_pki_nom_cert }}.crt"
client_pki_cle: "{{ client_pki_steppath }}/certs/{{ client_pki_nom_cert }}.key"
# SANs du certificat (FQDN + nom court + IP). Requis pour un mTLS moderne :
# les clients TLS valident par le SAN, pas par le CN.
client_pki_sans:
- "{{ client_pki_nom_cert }}"
- "{{ ansible_hostname | default('') }}"
- "{{ ansible_host | default('') }}"
# LE CERTIFICAT DOIT COUVRIR LES NOMS SOUS LESQUELS ON APPELLE LE SERVICE (2026-08-25).
#
# Les SANs ne portaient que l'identite de la MACHINE — FQDN, nom court, IP. Or un service
# se declare `expose:` au plan, sous un nom qui lui survivra : `forge.genese.internal`
# reste le nom de la forge meme si elle demenage de `site-forge-01` a `site-forge-02`.
#
# Ce nom etait publie partout — plancher /etc/hosts, zone DNS — et couvert nulle part. Le
# clonage du genome a bute dessus : « certificate subject name (site-forge-01.genese
# .internal) does not match target hostname 'forge.genese.internal' ». Du TLS correct, sur
# un nom que personne ne peut appeler : la meme panne que le depot a deja rencontree cote
# resolution, ici cote certificat.
#
# ON N'AJOUTE QUE CE QUE CET HOTE SERT REELLEMENT : `edge` nomme le groupe qui rend le
# service, et l'hote doit en faire partie. Un certificat qui revendiquerait le nom d'un
# service rendu ailleurs serait une usurpation, pas une commodite.
client_pki_expositions: "{{ hosts_statiques_expositions | default([]) }}"
client_pki_sans: >-
{{ ([client_pki_nom_cert,
ansible_hostname | default(''),
ansible_host | default('')]
+ (client_pki_expositions
| selectattr('edge', 'defined')
| selectattr('fqdn', 'defined')
| selectattr('edge', 'in', group_names)
| map(attribute='fqdn') | list))
| select | unique | list }}
# L'empreinte du root CA n'est PAS un intrant : elle est DERIVEE a chaud depuis l'AC
# (tasks/main.yml), parce qu'un from-zero regenere l'autorite avec une empreinte neuve.

View file

@ -1,23 +1,48 @@
---
# UN POSTE QUI NE SAIT PAS QUEL ÉCOSYSTÈME IL PILOTE N'A RIEN À PILOTER.
# UN POSTE QUI NE SAIT PAS SUR QUOI IL TRAVAILLE N'A RIEN À PILOTER.
#
# On exige explicitement un dépôt d'instance, plutôt que de retomber sur un défaut : le
# On exige explicitement ce qu'il pilote, plutôt que de retomber sur un défaut : le
# défaut nommait patient 0, et un écosystème distrait aurait cloné le génome d'un autre
# sans qu'aucune erreur ne le signale.
#
# DEUX FORMES DE RUNNER, ET UNE SEULE ÉTAIT PRÉVUE (2026-08-25).
#
# Un runner de TENANT pilote un écosystème : il monte son plan par le lien `instance`,
# détient sa voûte, et configure ses services. Il lui faut donc un dépôt `role: instance`
# et `serveur_ops_instance`.
#
# Un runner de SITE ne pilote aucun écosystème — il MATÉRIALISE le terrain que d'autres
# occuperont : VNets, VM, routes, flux. Son propre inventaire est DYNAMIQUE
# (`site_inventaire.py`), il n'a pas de lien `instance` à monter, et les dépôts de
# tenants qu'il porte sont marqués `role: tenant` précisément pour dire qu'il ne les
# pilote pas. Ce qu'il pilote, c'est la CARTE de l'hébergeur : `role: hebergeur` et
# `serveur_ops_underlay`.
#
# L'assertion n'admettait que la première forme. Le plan du site déclarait pourtant tout
# ce qu'il fallait, correctement et avec ses raisons écrites — et le rôle le refusait au
# nom d'une exigence qui ne le concernait pas.
#
# On exige donc que le runner pilote QUELQUE CHOSE, sans prescrire quoi.
- name: Exiger les intrants du poste d'exploitation
ansible.builtin.assert:
that:
- domaine_interne | default('') | length > 0
- (serveur_ops_depots | selectattr('role', 'eq', 'moteur') | list | length) == 1
- (serveur_ops_depots | selectattr('role', 'eq', 'instance') | list | length) >= 1
- serveur_ops_instance | default('') | length > 0
- >-
((serveur_ops_depots | selectattr('role', 'eq', 'instance') | list | length) >= 1
and serveur_ops_instance | default('') | length > 0)
or
((serveur_ops_depots | selectattr('role', 'eq', 'hebergeur') | list | length) >= 1
and serveur_ops_underlay | default('') | length > 0)
- not (serveur_ops_forge_externe | bool) or (serveur_ops_forge_amont | length > 0)
fail_msg: >-
serveur_ops exige `domaine_interne`, exactement un dépôt `role: moteur`, au moins
un dépôt `role: instance`, et `serveur_ops_instance` (le dossier que le lien
`instance` doit désigner). Et si `serveur_ops_forge_externe` est levé — écosystème
au premier âge, qui lit son génome chez son hôte — `serveur_ops_forge_amont` doit
nommer cette forge. Déclarer le tout dans group_vars/serveur_ops.yml.
serveur_ops exige `domaine_interne`, exactement un dépôt `role: moteur`, et de
savoir sur quoi il travaille — SOIT un dépôt `role: instance` avec
`serveur_ops_instance` (runner de TENANT : il pilote un écosystème), SOIT un dépôt
`role: hebergeur` avec `serveur_ops_underlay` (runner de SITE : il matérialise le
terrain, et son inventaire est dynamique). Et si `serveur_ops_forge_externe` est
levé — écosystème au premier âge, qui lit son génome chez son hôte —
`serveur_ops_forge_amont` doit nommer cette forge.
- name: Installer l'outillage du poste
ansible.builtin.apt:
@ -206,6 +231,15 @@
# LES DEUX SYMLINKS (D-80). `instance` dit QUEL tenant on pilote. Le poste en pose un par
# défaut ; l'exploitant le bascule comme sur le poste du mainteneur.
#
# UN RUNNER DE SITE N'EN A PAS (2026-08-26). Il ne pilote aucun écosystème — il matérialise
# le terrain — et son propre inventaire est DYNAMIQUE (`site_inventaire.py`). Son plan
# déclare donc `serveur_ops_instance: ""`, et c'est une réponse, pas un oubli.
#
# Sans la garde ci-dessous, la chaîne rendait `{{ racine }}/` + `""` : le lien `instance`
# pointait sur `/opt/setops/` lui-même — un répertoire qui n'est l'écosystème de personne.
# Mesuré le 2026-08-26 : `instance -> /opt/setops/`. Un lien qui existe et ne désigne rien
# est pire qu'un lien absent : tout ce qui le suit croit avoir trouvé une instance.
- name: Désigner l'instance pilotée par défaut
ansible.builtin.file:
src: "{{ serveur_ops_racine }}/{{ serveur_ops_instance }}"
@ -215,7 +249,20 @@
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
force: true
when: not ansible_check_mode
when:
- serveur_ops_instance | default('') | trim | length > 0
- not ansible_check_mode
# Et s'il n'y en a pas, le lien ne doit pas SURVIVRE d'un passage precedent : un runner
# qu'on reconfigure en runner de site garderait sinon un lien perime vers un tenant.
- name: Retirer le lien `instance` quand le poste ne pilote aucun écosystème
ansible.builtin.file:
path: >-
{{ serveur_ops_racine }}/{{ (serveur_ops_depots | selectattr('role', 'eq', 'moteur') | first).dest }}/instance
state: absent
when:
- serveur_ops_instance | default('') | trim | length == 0
- not ansible_check_mode
# LE SECOND SYMLINK. Sans lui, le poste sait configurer des machines qui existent ; il ne
# sait pas en creer, faute de savoir sur quelle fabric les poser. C'est la difference

129
scripts/genome_colis.py Normal file
View file

@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Porter des commits jusqu'a une forge qu'on ne route pas — par colis, pas par reseau.
POURQUOI CE DETOUR, ET POURQUOI IL EST BON.
La forge du genome vit DANS le site, sur un reseau que le poste de l'exploitant ne route
pas — et c'est voulu : le poste n'a de patte que sur l'administration, la frontiere police
le reste. Il ne peut donc pas pousser lui-meme.
On ne perce pas un chemin pour lui. Le RUNNER existe exactement pour ca : il est dans le
site, il porte le genome, il a une identite. On lui PORTE les commits dans un `git bundle`
— un fichier, verifiable, qui ne demande aucun reseau — et c'est LUI qui pousse.
Mesure du 2026-08-26 : la forge du site etait restee quatre commits derriere le poste, sans
que rien ne le signale. Le runner clonait donc un moteur perime — dont, ce jour-la, le
correctif qui desarme le pare-feu Proxmox. Un ecosysteme qui se reproduit depuis une forge
en retard reproduit ses defauts.
DEUX GESTES, DEUX COTES :
genome_colis.py emballer <depot> <colis>
Sur le CONTROLEUR. Fabrique le colis a partir de `main`.
genome_colis.py recevoir <depot> <colis> <pushurl> [--cle <fichier>]
Sur le RUNNER. Verifie le colis, avance `main` EN AVANCE RAPIDE SEULEMENT,
puis pousse. Rend du JSON : {avant, apres, pousse}.
`--ff-only` est le garde-fou. Si les deux histoires ont diverge, on s'arrete plutot que de
fabriquer une fusion que personne n'a decidee — et le message de git nomme alors exactement
ce qu'il faut reconcilier.
CE SCRIPT VIT DANS LE MOTEUR, donc le runner en a une copie des qu'il l'a clone : on ne
depose rien de special sur la machine pour qu'elle sache recevoir.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
def _git(depot: Path, *args: str, env: dict | None = None) -> str:
"""Un git, dans un depot, qui parle clair quand il echoue."""
r = subprocess.run(["git", "-C", str(depot), *args],
capture_output=True, text=True, env=env)
if r.returncode != 0:
sortie = (r.stderr or r.stdout or "").strip()
raise SystemExit(f"git {' '.join(args)} a echoue dans {depot} :\n {sortie}")
return (r.stdout or "").strip()
def emballer(depot: Path, colis: Path, branche: str) -> int:
if not (depot / ".git").exists():
raise SystemExit(f"{depot} n'est pas un depot git — rien a emballer.")
colis.parent.mkdir(parents=True, exist_ok=True)
_git(depot, "bundle", "create", str(colis), branche)
print(json.dumps({"depot": str(depot), "colis": str(colis),
"tete": _git(depot, "log", "-1", "--format=%H")}))
return 0
def recevoir(depot: Path, colis: Path, pushurl: str, cle: str | None,
branche: str) -> int:
if not (depot / ".git").exists():
raise SystemExit(f"{depot} n'est pas un depot git — rien a recevoir.")
if not colis.is_file():
raise SystemExit(f"{colis} est introuvable : le colis n'est pas arrive.")
# UN COLIS DOIT ETRE VERIFIABLE AVANT D'ETRE CRU. `git bundle verify` exige d'etre
# lance DEPUIS un depot — il verifie que les prerequis du colis sont deja dans cette
# histoire-ci. Lance ailleurs : « la verification d'un colis requiert un depot ».
_git(depot, "bundle", "verify", str(colis))
avant = _git(depot, "log", "-1", "--format=%H")
_git(depot, "fetch", str(colis),
f"refs/heads/{branche}:refs/remotes/colis/{branche}")
_git(depot, "merge", "--ff-only", f"refs/remotes/colis/{branche}")
apres = _git(depot, "log", "-1", "--format=%H")
env = None
if cle:
import os
env = dict(os.environ)
env["GIT_SSH_COMMAND"] = (
f"ssh -o StrictHostKeyChecking=accept-new -o BatchMode=yes -i {cle}")
# `-c remote.origin.pushurl=…` plutot qu'un remote ecrit : la LECTURE reste anonyme
# (doctrine de `serveur_ops` — aucun justificatif a detenir pour se reconstruire),
# seule l'ECRITURE s'authentifie, et seulement le temps de cette commande.
r = subprocess.run(
["git", "-C", str(depot), "-c", f"remote.origin.pushurl={pushurl}",
"push", "origin", branche],
capture_output=True, text=True, env=env)
sortie = ((r.stdout or "") + (r.stderr or "")).strip()
if r.returncode != 0:
raise SystemExit(f"la poussee a echoue :\n {sortie}")
print(json.dumps({"avant": avant, "apres": apres,
"pousse": f"{branche} -> {branche}" in sortie, "sortie": sortie[:400]}))
return 0
def main(argv: list[str]) -> int:
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
sous = p.add_subparsers(dest="geste", required=True)
e = sous.add_parser("emballer", help="fabriquer un colis depuis `main` (controleur)")
e.add_argument("depot")
e.add_argument("colis")
e.add_argument("--branche", default="main")
r = sous.add_parser("recevoir", help="verifier, avancer, pousser (runner)")
r.add_argument("depot")
r.add_argument("colis")
r.add_argument("pushurl")
r.add_argument("--cle", default=None, help="cle SSH du runner")
r.add_argument("--branche", default="main")
a = p.parse_args(argv)
if a.geste == "emballer":
return emballer(Path(a.depot), Path(a.colis), a.branche)
return recevoir(Path(a.depot), Path(a.colis), a.pushurl, a.cle, a.branche)
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))