Set-OPS-Public/scripts/forge_amorcer.py
Daniel Allaire bef0655112 le site reconstruit depuis zero : 7/7, 0 echec, et seize corrections
SITE-Chezlepro n avait jamais ete rase. La limite qu on repetait partout —
l infrastructure d accueil n a jamais ete reconstruite depuis zero — se
lisait comme de la prudence. C etait seize defauts que rien d autre n aurait
pu reveler.

Un locataire naît dans un monde deja peuple : le site lui fournit paquets,
noms, genome, heure et depot. Un site n a personne au-dessus, sauf sa
frontiere. Onze des seize murs viennent de la.

DEUX CAPACITES QUI N EXISTAIENT PAS. make site-raser — rien ne detruisait les
machines du site, donc la limite etait un trou d outillage. make
forge-amorcer — la forge naît vide et le runner y clone ; l amorcage part du
poste, seul endroit qui detienne alors le genome.

TROIS GARDES QUI VERIFIAIENT LA FORME. La plus couteuse des familles : elles
donnent l apparence d une verification. Le resolveur comparait des adresses
au lieu de mesurer si la resolution aboutit, et protegeait ainsi l etat
casse. L administration etait reconnue a son port. Un flux a deux paires
n obtenait qu une branche.

UN ECART DE SECURITE. Le PostgreSQL du site servait le certificat auto-signe
de Debian, sans reseaux autorises ni hostssl — invisible tant qu aucun client
n exigeait la verification. Le defaut n a pas casse la construction : la
construction a revele le defaut.

UN ACCES ACCIDENTEL. Celui de l exploitant tenait au chevauchement d
adressage que le renumerotage a supprime. Separer les index n a pas cause le
probleme, il a retire le hasard qui le masquait.

La sequence du premier jour est ecrite : runbooks §9, avec les seize murs et
ce que chacun enseigne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-12 19:08:31 -04:00

212 lines
9.3 KiB
Python

#!/usr/bin/env python3
"""Amorce la forge d'un SITE neuf avec le genome. Le maillon qui n'existait pas.
POURQUOI CE SCRIPT EXISTE (2026-09-12, premiere reconstruction du site depuis zero).
Le runner du site clone le genome depuis SA PROPRE forge — `serveur_ops_forge_hote`
derive de `groups['serveur_forgejo']`. L'ordre des couches est juste : `serveur_forgejo`
precede `serveur_ops`. Mais `roles/serveur_forgejo` ne cree NI organisation NI depot : a
froid, la forge naît vide, et le `git clone` du runner echoue apres trois essais.
LE DEPLOIEMENT N'AVAIT JAMAIS RENCONTRE CE CAS parce que la forge existait depuis le
premier jour, remplie a la main, a une date que personne n'a notee. C'est exactement ce
qu'une limite « jamais reconstruit depuis zero » cache : pas une fragilite, un MAILLON
QUI N'AVAIT JAMAIS ETE FABRIQUE PAR LE CODE.
POURQUOI DEPUIS LE POSTE, ET PAS DEPUIS UN ROLE. A cet instant precis, le poste est le
SEUL endroit qui detienne le genome : la forge est vide et le runner n'existe pas encore.
Un role qui tenterait de se servir lui-meme tournerait en rond. C'est un geste d'amorcage,
et un amorcage vient toujours de l'exterieur de ce qu'il amorce.
LA LISTE VIENT DE LA MEME DECLARATION QUE LE RUNNER LIT — `serveur_ops_depots` dans le
plan du site. Ecrire une seconde liste ici la ferait diverger de celle qui compte, et ce
depot passe son temps a supprimer ce genre de paire.
Usage :
python3 scripts/forge_amorcer.py # montre ce qui serait pousse
python3 scripts/forge_amorcer.py --confirmer
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
FRERES = RACINE.parent
sys.path.insert(0, str(RACINE / "scripts"))
import underlay as underlay_mod # noqa: E402
def _depots_declares() -> list[dict]:
"""`serveur_ops_depots` du plan du SITE — la source que le runner lit."""
import yaml
plan = underlay_mod.lire_plan_site("serveurs.yml") or {}
for srv in (plan.get("serveurs") or {}).values():
d = ((srv.get("variables") or {}).get("serveur_ops_depots"))
if d:
return list(d)
return []
def _api(base: str, chemin: str, methode: str = "GET", corps: dict | None = None,
auth: str = "") -> tuple[int, object]:
req = urllib.request.Request(f"{base}/api/v1{chemin}", method=methode)
req.add_header("Authorization", f"Basic {auth}")
req.add_header("Content-Type", "application/json")
donnees = json.dumps(corps).encode() if corps is not None else None
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE # la racine step-ca n'est pas encore au magasin du poste
try:
with urllib.request.urlopen(req, donnees, timeout=25, context=ctx) as r:
return r.status, json.loads(r.read() or b"null")
except urllib.error.HTTPError as e:
return e.code, (e.read() or b"").decode()[:200]
except OSError as e:
return 0, str(e)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--forge", help="URL de la forge (defaut : derivee du plan du site)")
ap.add_argument("--organisation", default="genome")
ap.add_argument("--utilisateur", default="forgejo-admin")
ap.add_argument("--cle-publique",
default=str(Path.home() / ".ssh" / "id_ed25519_ansible_chezlepro.pub"),
help="cle publique a enregistrer dans la forge neuve (pour le push SSH)")
ap.add_argument("--confirmer", action="store_true")
a = ap.parse_args()
depots = _depots_declares()
if not depots:
print("Aucun `serveur_ops_depots` au plan du site : rien a amorcer.")
return 2
# L'hote de la forge vient du plan, comme pour le runner.
import yaml
apps = (underlay_mod.lire_plan_site("applications.yml") or {}).get("applications") or {}
hote = next((str(x.get("hote")) for x in apps.values()
if str(x.get("groupe")) == "serveur_forgejo"), "")
fqdn = next((str(f) for x in apps.values()
if str(x.get("groupe")) == "serveur_forgejo"
for f in (x.get("expose") or [])), "")
base = a.forge or (f"https://{fqdn}" if fqdn else "")
if not base:
print("La forge du site ne se derive pas du plan : passer --forge.")
return 2
print(f"Forge : {base} (machine {hote or '?'})")
print(f"Organisation : {a.organisation}\n")
manquants, prets = [], []
for d in depots:
local = FRERES / str(d.get("dest"))
if not (local / ".git").is_dir():
manquants.append(f"{d['dest']} — absent du poste ({local})")
else:
prets.append((str(d["depot"]), local, str(d.get("branche") or "main")))
for nom, local, branche in prets:
print(f" {nom:22} <- {local.name:20} branche {branche}")
for m in manquants:
print(f" MANQUANT {m}")
if manquants:
print(f"\nREFUS : {len(manquants)} depot(s) declare(s) n'existent pas sur ce poste.\n"
f"Amorcer une forge avec une liste incomplete la rendrait credible et fausse :\n"
f"le runner clonerait ce qui est la et echouerait sur le reste, plus tard.")
return 2
if not a.confirmer:
print(f"\n{len(prets)} depot(s) seraient crees puis pousses.\n"
f"Relancer avec : make forge-amorcer CONFIRMER=true")
return 0
mdp = os.environ.get("SETOPS_FORGE_MDP", "")
if not mdp:
print("\nREFUS : mot de passe de l'administrateur de la forge absent.\n"
" export SETOPS_FORGE_MDP=\"$(ansible-vault view … | …)\" puis relancer.\n"
"Il n'est pas lu du depot : un secret ne traverse pas un argument de ligne\n"
"de commande, ou il finirait dans l'historique du shell.")
return 2
auth = base64.b64encode(f"{a.utilisateur}:{mdp}".encode()).decode()
# LA FORGE NEUVE NE CONNAIT PERSONNE (2026-09-12). Les remotes `origin` poussent en
# `ssh://git@…` ; une Forgejo fraiche rejette la cle de l'exploitant — `Permission
# denied (publickey)` — parce qu'aucune cle n'y est enregistree. Amorcer sans cela
# revient a creer des depots qu'on ne peut pas remplir.
cle = Path(a.cle_publique)
if cle.is_file():
st, rep = _api(base, "/user/keys", "POST",
{"title": f"amorcage-{cle.stem}", "key": cle.read_text().strip()}, auth)
if st in (201, 200):
print(f" cle publique {cle.name} : enregistree")
elif st == 422:
print(f" cle publique {cle.name} : deja la")
else:
print(f" cle publique {cle.name} : ECHEC {st} {rep}")
return 2
else:
print(f" cle publique absente ({cle}) — le push SSH echouera")
return 2
st, _ = _api(base, f"/orgs/{a.organisation}", auth=auth)
if st == 404:
st, rep = _api(base, "/orgs", "POST", {"username": a.organisation}, auth)
print(f" organisation {a.organisation} : "
+ ("creee" if st in (201, 200) else f"ECHEC {st} {rep}"))
elif st == 200:
print(f" organisation {a.organisation} : deja la")
else:
print(f" organisation {a.organisation} : ECHEC {st}")
return 2
echecs = 0
for nom, local, branche in prets:
st, _ = _api(base, f"/repos/{a.organisation}/{nom}", auth=auth)
if st == 404:
st, rep = _api(base, f"/orgs/{a.organisation}/repos", "POST",
{"name": nom, "private": False}, auth)
# PUBLIC DANS LE PERIMETRE DE LA FORGE, ET C'EST VOULU (2026-09-12).
#
# Cree en `private` au premier essai — par reflexe. Le runner clone
# ANONYMEMENT : la tache `git` de `serveur_ops` ne porte aucun identifiant,
# et l'echec ne dit pas « depot prive », il dit :
#
# could not read Username for 'https://forge.genese.internal'
#
# La forge n'est joignable que depuis les zones du site, les supernets
# locataires et le plan d'administration : « public » y signifie « lisible
# par ceux qui ont deja franchi la frontiere ». Les secrets, eux, sont dans
# des voutes chiffrees — c'est la qu'est la protection, pas dans un drapeau
# de visibilite qui casserait la reproduction.
if st not in (201, 200):
print(f" {nom:22} ECHEC creation {st} {rep}")
echecs += 1
continue
# `--force` JAMAIS : si le depot porte deja quelque chose, on veut le savoir,
# pas l'ecraser. Un amorcage n'a de sens que sur une forge vide.
r = subprocess.run(["git", "-C", str(local), "push", "origin", branche],
capture_output=True, text=True, timeout=180)
if r.returncode == 0:
print(f" {nom:22} pousse ({branche})")
else:
print(f" {nom:22} ECHEC push : {(r.stderr or '').strip().splitlines()[-1][:90]}")
echecs += 1
print(f"\n{len(prets) - echecs}/{len(prets)} depot(s) amorces.")
return 1 if echecs else 0
if __name__ == "__main__":
raise SystemExit(main())