pools nommes comme les depots, et les cles sortent du poste

LE NOM D UN POOL EST CELUI DE SON DEPOT. Chezlepro-17 devient OPS-Chezlepro :
le seed se lisait dans le nom, ce qui obligeait a connaitre le codage — et
surtout le nom CHANGEAIT si l index changeait, ce que la renumerotation du
site a montre le jour meme.

Site-OPS ne derive de rien, et c est le point : les machines du genome ne
dependent d aucun index, elles sont l infrastructure SUR laquelle les index
vivent. Sans ce bloc elles restaient hors de tout pool.

P69 — l amorcage d un tenant designe-t-il le site REEL ? dns_amorcage et
artefacts_amorcage sont ecrits a la main, volontairement : au moment ou ils
servent la machine ne resout aucun nom. Mais ils designent des machines DU
SITE, et n ont pas suivi son renumerotage. La reconstruction du locataire
s est arretee sur Failed to update apt cache, a quinze couches de sa cause.
La preuve ne juge que les valeurs qui PRETENDENT designer le site : viser
9.9.9.9 est un choix, pas un oubli.

LES CLES SORTENT DU POSTE, EN CLAIR, ET C EST RAISONNE. Support perdu : LUKS
s en charge. Poste compromis : la seconde couche n aide pas, les originaux
sont dans ~/.config sur ce meme poste. Elle coutait une phrase de passe
stockee nulle part — le seul point que la procedure ne couvre pas. Option
--support-chiffre explicite ; le defaut reste GPG, parce qu un support non
chiffre est le cas le plus frequent.

Et ma note qui disait les cles sorties depuis le 5 septembre etait fausse :
le support ne portait que le depot hors site du 1er.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
This commit is contained in:
Daniel Allaire 2026-09-12 21:37:28 -04:00
parent b9b0d87db2
commit 56d202a8bb
8 changed files with 229 additions and 11 deletions

View file

@ -188,7 +188,7 @@ Si `ansible-lint` n’est pas disponible, le signaler clairement. Ne pas invente
## Écrire, puis relire (D-68)
`--syntax-check` et `ansible-lint` prouvent que le dépôt est cohérent **avec lui-même**.
C'est aussi ce que font les 68 preuves de `make prouver` : elles lisent le dépôt, sans le
C'est aussi ce que font les 69 preuves de `make prouver` : elles lisent le dépôt, sans le
moindre appel réseau. **Aucune ne demande au système déployé s'il ressemble à ce que le
dépôt annonce.**

View file

@ -1,5 +1,59 @@
# CHANGELOG — Set-OPS
## 2026-09-12 (4) — Pools nommes comme les depots, et les cles sortent du poste
### Le nom d'un pool est celui de son depot
`Chezlepro-17` devient `OPS-Chezlepro`. Le seed se lisait dans le nom, ce qui etait juste
mais obligeait a connaitre le codage — et surtout **le nom CHANGEAIT si l'index changeait** :
la renumerotation du site l'a montre le jour meme. Ce qu'on lit dans la console est
desormais ce qu'on tape dans un terminal.
L'unicite ne vient plus de l'index mais du nom de dossier, deja unique par construction.
**`Site-OPS` ne derive de rien, et c'est le point.** Les machines du genome — cache, forge,
AC, noms, depot, supervision — ne dependent d'aucun index : elles sont l'infrastructure SUR
laquelle les index vivent. Un nom invariable dit cela, et il reste le meme d'un hebergeur a
l'autre. Sans ce bloc, les sept restaient hors de tout pool : sept orphelines a cote de
trois flottes rangees.
CONFORME : 4 pool(s) Proxmox, 41 VM placee(s), aucun nom ni VMID en collision.
### P69 — l'amorcage d'un tenant designe-t-il le site REEL ?
`dns_amorcage` et `artefacts_amorcage` sont ecrits A LA MAIN dans les intrants d'un tenant,
et c'est voulu : au moment ou ils servent, la premiere machine ne resout aucun nom. Une
adresse, pas un nom.
Mais ces adresses designent des machines DU SITE. Le site a change d'index ; elles n'ont
pas suivi. La reconstruction du locataire s'est arretee sur `infra-pki-01` avec
« Failed to update apt cache » — a quinze couches de sa cause.
La preuve ne juge que les valeurs qui PRETENDENT designer le site : un tenant peut
legitimement s'amorcer sur un resolveur public, et `OPS-Technolibre` comme `OPS-Patient0`
visent `9.9.9.9`. C'est un choix, pas un oubli.
### Les cles sortent du poste, en clair, et c'est raisonne
Les neuf fichiers (1 644 o) sont sur la cle USB LUKS. **En tar clair**, par
`--support-chiffre`, option ajoutee ce jour et explicite — le defaut reste GPG.
Deux menaces, deux reponses :
le support est perdu ou vole -> LUKS s'en charge
le poste est compromis -> la 2e couche n'aide pas : les originaux sont
dans ~/.config sur ce meme poste
La couche GPG n'ajoutait donc presque rien, et coutait une phrase de passe STOCKEE NULLE
PART — le seul point que la procedure elle-meme ne couvre pas. On echangeait un risque de
divulgation deja couvert contre un risque de PERTE qui ne l'etait pas.
Pourquoi ce n'est pas le defaut : un support non chiffre est le cas le plus frequent, et
le silence ne doit jamais pencher du cote de la divulgation.
**Et la note qui disait les cles sorties depuis le 2026-09-05 etait fausse** : le support
ne portait que le depot hors site du 1er. Verifie avant d'ecrire, pas apres.
## 2026-09-12 (3) — Trois reconstructions : trouver, verifier, prouver
La premiere reconstruction avait trouve seize defauts. Deux tours de plus ont ete faits, et

View file

@ -7,7 +7,7 @@
> [`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/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (67 OK · 0 echec · 1 saute)
- **Verdict** : ✅ CONFORME (68 OK · 0 echec · 1 saute)
## Preuves
@ -40,7 +40,7 @@
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 49 groupe(s), 87 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 14 hote(s) x 6 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, 34 VM placee(s), aucun nom ni VMID en collision. |
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 4 pool(s) Proxmox, 41 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 33 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 22, 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 | 62 scripts expliques et atteignables, 119 cibles make documentees, 68 roles avec README. |
@ -69,7 +69,7 @@
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. |
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 14 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 14. |
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (68 preuves, 68 roles, 41 groupes). |
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (69 preuves, 68 roles, 41 groupes). |
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `904ece3` (publie le 2026-09-12). |
@ -81,6 +81,7 @@
| P66 | Clients OIDC : chaque URI vise un nom que le plan expose | — | ✅ OK | 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (6 exposition(s)). |
| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 6 service(s) expose(s) portent le nom du plan (6 groupe(s) derive(s)). |
| P68 | Cle de depot telechargee : mesuree avant d'etre utilisee | — | ✅ OK | 5 role(s) telechargent une cle de depot, tous la mesurent avant de s'en servir. |
| P69 | Amorcage d'un tenant : l'adresse designe le site REEL | — | ✅ OK | 2 adresse(s) d'amorcage designent bien une machine du site. |
## Couverture des affirmations ✅ du registre

View file

@ -30,7 +30,7 @@ make placement-plan # chaque VM est-elle là où le plan la met
## Le trou qu'il comble
`scripts/prouver.py` porte 68 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
`scripts/prouver.py` porte 69 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est
cohérent **avec lui-même** — que les handlers existent, que les intrants ont un
propriétaire, que rien n'est codé en dur.

View file

@ -42,7 +42,7 @@ import yaml
RACINE = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(RACINE / "scripts"))
from inventory_rules import (
from inventory_rules import (POOL_SITE,
instance_courante, # noqa: E402
charger_serveurs,
deriver_nomenclature,
@ -172,6 +172,29 @@ def main(argv: list[str]) -> int:
return 0
devis = construire(decouvrir())
# LE SITE EST UN POOL AUSSI (2026-09-12). Les machines du genome — cache, forge, AC,
# noms, depot, supervision — ne derivent d'aucun index : elles sont l'infrastructure
# SUR laquelle les index vivent. D'ou un nom invariable, `Site-OPS`, identique d'un
# hebergeur a l'autre.
#
# Sans ce bloc, elles restaient hors de tout pool : dans la console, sept machines
# sans appartenance a cote de trois flottes rangees — exactement le desordre que le
# reste du devis supprime.
try:
import underlay as _u
_plan = (_u.lire_plan_site("serveurs.yml") or {}).get("serveurs") or {}
_membres = sorted(
({"nom": n, "vmid": int(v["vmid"]), "etat": str(v.get("etat", "actif"))}
for n, v in _plan.items() if str(v.get("vmid", "")).strip().isdigit()),
key=lambda m: m["vmid"])
if _membres:
devis["pools"].insert(0, {
"pool": POOL_SITE, "tenant": "(site)", "index": None,
"membres": _membres, "sans_vmid": [],
})
except Exception:
pass # aucun underlay monte : le site n'existe pas de ce point de vue
if args.verifier:
blocs = devis["pools"]
if not blocs:

View file

@ -174,6 +174,9 @@ def main() -> int:
# phrase de passe et on ne risque pas d'ecraser ce qui est deja verifie.
ap.add_argument("--compagnons", action="store_true",
help="Deposer seulement le script de restauration et son mode d'emploi")
ap.add_argument("--support-chiffre", action="store_true",
help="le support est DEJA chiffre au repos (LUKS) : ecrire un tar "
"clair plutot qu'une archive GPG (voir la note dans le code)")
ap.add_argument("--nom", default="setops-cles",
help="Nom de base de l'archive (defaut: setops-cles)")
args = ap.parse_args()
@ -220,6 +223,53 @@ def main() -> int:
print(" RECENSEMENT SEUL — rien n'a ete ecrit.")
return 0
# LE SUPPORT EST DEJA CHIFFRE : LA SECONDE COUCHE PROTEGE DE QUOI ? (2026-09-12)
#
# La question posee par l'exploitant, et elle se tient. Deux menaces, deux reponses :
#
# Le support est PERDU ou VOLE — LUKS s'en charge. Sans la phrase de deverrouillage
# du volume, il n'y a rien a lire.
#
# Le POSTE est compromis — la seconde couche n'aide pas : les originaux sont dans
# `~/.config` sur ce meme poste. Qui tient le poste tient deja les cles ; la copie
# chiffree ne lui retire rien.
#
# La couche GPG n'ajoute donc presque rien, et coûte une phrase de passe STOCKEE
# NULLE PART — que la procedure elle-meme designe comme le seul point non couvert :
# perdue, l'archive est du bruit. On echange un risque de divulgation deja couvert
# contre un risque de PERTE qui ne l'est pas.
#
# `--support-chiffre` est donc un choix legitime, mais il DOIT etre explicite : le
# defaut reste l'archive GPG, parce qu'un support non chiffre est le cas le plus
# frequent et que le silence ne doit jamais pencher du cote de la divulgation.
if args.support_chiffre:
archive = dest / f"{args.nom}-{os.uname().nodename}.tar"
if archive.exists():
raise SystemExit(
f"REFUS : {archive} existe deja.\n"
" On n'ecrase pas une sauvegarde de cles : elle est peut-etre la seule.\n"
" La deplacer ou choisir un autre --nom.")
with tarfile.open(archive, "w") as tar:
for f, _ in fichiers:
tar.add(f, arcname=f.name)
archive.chmod(0o600)
# RELIRE CE QU'ON VIENT D'ECRIRE, comme pour l'archive chiffree : une sauvegarde
# qu'on n'a pas relue est une intention.
with tarfile.open(archive, "r") as tar:
noms = set(tar.getnames())
manquants = [f.name for f, _ in fichiers if f.name not in noms]
if manquants:
archive.unlink(missing_ok=True)
raise SystemExit(f"ECHEC de la relecture — archive SUPPRIMEE. "
f"Manquent : {', '.join(manquants)}")
print(f" Ecrit EN CLAIR dans l'archive : {archive}")
print(f" {len(noms)} fichier(s) relus et retrouves.\n")
print(" LA PROTECTION EST CELLE DU SUPPORT. Ce tar est lisible par quiconque")
print(" monte ce volume deverrouille. Il n'a sa place que sur un support chiffre")
print(" au repos, et le volume doit etre DEMONTE quand on ne s'en sert pas.")
_deposer_compagnons(dest)
return 0
archive = dest / f"{args.nom}-{os.uname().nodename}.tar.gpg"
if archive.exists():
raise SystemExit(

View file

@ -312,12 +312,35 @@ def pool_de(nom_instance: str, index: int) -> str:
justement la frontiere que Set-OPS etablit. La flotte derivee a donc ses propres
pools, nommes comme tout le reste : depuis le seed.
`index` est deja garanti unique dans la federation par P21 : le nom l'est donc
aussi, sans registre supplementaire. Forme : `Chezlepro-17`, `Technolibre-11`.
LE NOM DU POOL EST CELUI DU DEPOT (2026-09-12, decision de l'exploitant). C'etait
`Chezlepro-17` — le seed lu dans le nom. Juste, mais il obligeait a connaitre le
codage pour relier un pool a son depot, et il CHANGEAIT si l'index changeait : la
renumerotation du site l'a montre le jour meme.
Un pool porte desormais le nom du depot, tel quel : `OPS-Chezlepro`,
`OPS-Technolibre`. Ce qu'on lit dans la console est ce qu'on tape dans un
terminal, et un renumerotage ne renomme plus rien.
L'unicite ne vient plus de l'index mais du nom de dossier — qui est deja unique
par construction, deux depots freres ne pouvant porter le meme. `index` reste au
parametre : il ne sert plus au nom, il sert a refuser un appel incoherent.
Les pools `Env.Tenant` anterieurs a Set-OPS (`Prod.Chezlepro`, `Lab.KBR`...)
restent l'ANCIEN MONDE : on n'y touche pas, et on n'y verse pas la flotte generee.
"""
base = re.sub(r"^OPS-", "", nom_instance)
base = re.sub(r"[^A-Za-z0-9._-]", "", base) or "tenant"
return f"{base}-{int(index)}"
valider_index(index)
base = re.sub(r"[^A-Za-z0-9._-]", "", nom_instance) or "tenant"
return base
# LE POOL DU SITE NE DERIVE DE RIEN, ET C'EST LE POINT (2026-09-12). Les machines du
# genome — celles qui portent le cache, la forge, l'AC, les noms et le depot — ne
# dependent d'aucun index : elles sont l'infrastructure SUR laquelle les index vivent.
# Un nom invariable dit exactement cela, et il reste le meme d'un hebergeur a l'autre.
#
# A ne pas confondre avec le pool `Set-OPS` deja present sur le cluster de reference,
# qui appartient a l'ancien monde.
POOL_SITE = "Site-OPS"
def est_groupe_operationnel(groupe: str) -> bool:

View file

@ -3278,6 +3278,71 @@ def preuve_cle_telechargee_non_vide() -> tuple[bool, str]:
f"avant de s'en servir.")
def preuve_amorcage_suit_le_site() -> tuple[bool, str]:
"""Les adresses d'amorcage d'un tenant designent-elles le site REEL ?
UNE SECONDE LISTE QUI SUIT UNE PREMIERE (2026-09-12). `dns_amorcage` et
`artefacts_amorcage` sont ecrits A LA MAIN dans les intrants d'un tenant, et c'est
voulu : au moment ou ils servent, la premiere machine du tenant ne resout aucun nom.
Une adresse, pas un nom — la declaration le dit explicitement.
Mais ces adresses designent des machines DU SITE. Quand le site a pris son propre
index et que ses zones sont passees de `10.0.3x` a `10.37.3x`, elles n'ont pas suivi :
la reconstruction du locataire s'est arretee sur `infra-pki-01`, apt muet, avec un
`resolv.conf` pointant une machine qui n'existait plus.
Le message ne disait pas « adresse perimee ». Il disait « Failed to update apt cache »,
a quinze couches de sa cause.
CE QUE CETTE PREUVE NE FAIT PAS : juger une adresse qui ne designe pas le site. Un
tenant peut legitimement s'amorcer sur un resolveur public — `OPS-Technolibre` et
`OPS-Patient0` visent `9.9.9.9`, et c'est un choix, pas un oubli. On ne verifie que
les valeurs qui PRETENDENT designer une machine du site : meme troisieme octet de
zone, autre deuxieme octet. Une adresse etrangere au site n'est pas notre affaire.
"""
try:
sys.path.insert(0, str(RACINE / "scripts"))
import underlay as _u
if not _u.charger():
return True, "Aucun underlay monte : aucun site a suivre."
attendus = {
"dns_amorcage": _u.adresses_site_portant("serveur_resolveur"),
"artefacts_amorcage": _u.adresses_site_portant("serveur_artefacts"),
}
except Exception as e:
return True, f"Site illisible ({type(e).__name__}) : rien a comparer."
if not any(attendus.values()):
return True, "Le site ne porte ni resolveur ni cache : rien a suivre."
# Les troisiemes octets des zones du site — la signature d'une adresse « du site ».
octets_site = {a.split(".")[2] for v in attendus.values() for a in v if a.count(".") == 3}
fautes, verifies = [], 0
for intrants in sorted((RACINE.parent).glob("OPS-*/inventories/*/group_vars/all/10-intrants.yml")):
try:
d = yaml.safe_load(intrants.read_text(encoding="utf-8")) or {}
except Exception:
continue
for cle, bons in attendus.items():
brut = str(d.get(cle) or "").strip()
if not brut or not bons:
continue
adr = brut.split(":")[0].split(",")[0].strip()
if adr.count(".") != 3 or adr.split(".")[2] not in octets_site:
continue # ne pretend pas designer le site : pas notre affaire
verifies += 1
if adr not in bons:
fautes.append(f"{intrants.parents[3].name} : `{cle}` vise {adr}, "
f"le site porte {', '.join(bons)}")
if not verifies:
return True, "Aucun tenant ne s'amorce sur une adresse du site."
if fautes:
return False, ("Adresse d'amorcage perimee — la premiere machine du tenant ne "
"resoudra rien, et l'erreur parlera d'apt :\n - "
+ "\n - ".join(fautes))
return True, (f"{verifies} adresse(s) d'amorcage designent bien une machine du site.")
PREUVES: list[dict] = [
{"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"],
"cmds": [["ansible-lint", "-q"]]},
@ -3434,6 +3499,8 @@ PREUVES: list[dict] = [
"refs": [], "func": preuve_nom_public_vient_du_plan},
{"id": "P68", "titre": "Cle de depot telechargee : mesuree avant d'etre utilisee",
"refs": [], "func": preuve_cle_telechargee_non_vide},
{"id": "P69", "titre": "Amorcage d'un tenant : l'adresse designe le site REEL",
"refs": [], "func": preuve_amorcage_suit_le_site},
]