Plan — Refonte nestor : graphe Python figé + moteur de chemin (zéro bash d'orchestration)
État : Actif (2026-06-25) · Fonde : ADR 0096 (Accepted) + ADR 0097 (Accepted). · Preuve :
bench/lima/RESULTS.md.ADR fondateurs
Accepted(2026-06-25) ⇒ implémentation des lots autorisée (ADR 0057 §6). L’étape 1 (factorisation pure, fix de bug sans ADR : elle ne décide rien de structurant, elle corrige une classe de bug existante) est le premier pas ; les lots 2-9 suivent, chacun prouvé au banc puis en prod.
Met en œuvre la refonte de nestor décidée par
ADR 0096 (graphe
de topologie Python figé, vérifié contre Ansible par un check qui notifie)
et ADR 0097
(moteur de chemin Python ; bash réduit aux artefacts node-side ; paramétrage
100 % YAML ; deux topologies pilotées par le même moteur). Ce plan livre
l’étape 1 maintenant (fix de fidélité preview/next/up, sans ADR) puis
cadre les lots cible (2-9), gelés jusqu’à l’acceptation des deux ADR.
ADR fondateurs
Section intitulée « ADR fondateurs »- 0096 — le
premier pilier : le graphe (
nestor/graph.py) est la source unique de l’ordre inter-composant, du périmètre de rollback (4 dimensions) et du signal ;scripts/check_topology.pynotifie la divergence graphe ↔ Ansible. Implémente en Python 0066 / 0069 / 0083. - 0097 — le second
pilier :
nestor/path.pyabsorbe l’orchestration derun-phases.sh; un seul sens d’appel Python → bash ;cni.sh/cleanup.shrestent des artefacts node-side ; paramétrage YAML ; les deux topologies. Supersede partiellement 0049, clôt l’inversion de 0063. - 0017 — bash orchestre vs Python testé : la frontière évolue, le bash d’orchestration part en pytest.
- 0034 /
0052 — preuve banc
from-scratch AVANT prod ; idempotence rejeu
changed=0. - 0046 — corriger le code, pas l’état : chaque correctif repart dans le code versionné, re-prouvé par un run.
- 0053 /
0090 — gardes d’isolation
banc/prod traversées à chaque phase (invariant de boucle) ;
nestorpilote la prod. - 0056 — modèle déclaratif
: une topologie = un YAML auto-suffisant (fonde le paramétrage 100 % YAML
du lot 8) ; ce plan prolonge
plan-modele-declaratif.md(palier P9). - 0023 — valeurs génériques
:
node1…node4,banc,local-path,ceph,platform-cnpg,platform-s3-bucket,marquez,dagster.
Contexte — le fil rouge
Section intitulée « Contexte — le fil rouge »L’audit des causes racines (« pourquoi revient-on souvent aux mêmes erreurs ? ») tranche : le même fait existe en trois représentations synchronisées à la main par le commit, jamais par le code ni par un test.
- Le graphe est déclaré en bash (
bench/lima/rollback-lib.sh:component_deps,component_namespace,component_targeted,component_crd_groups,component_has_nodeside,component_alias_weight,component_profile). - Sa projection nestor ne re-déclare pas le graphe : elle le consulte en
shellant bash (
nestor/layers.py:91_rb,nestor/roundtrip.py_rollback_lib_call) — unsubprocessqui sourcerollback-lib.shà chaque appel. - Le signal de santé vit dans une 3ᵉ table séparée (
_LAYER_SIGNAL,scripts/topology.py:739), mappant une phase → un seul Deployment discriminant.
Les primitives sont uniques (topo_sort, component_deps) — ce ne sont pas
elles le problème. Ce sont leurs câblages et leurs miroirs qui ne le
sont pas : chaque correctif corrige UN miroir, l’autre reste. D’où les erreurs
récurrentes :
- « Marquez oublié » :
dataopsa deux feuilles (dagsterETmarquez), mais_LAYER_SIGNAL["dataops"]ne sonde quedagster-dagster-webserver(topology.py:751) → le verdict « DataOps complet » peut mentir sur un drift de Marquez (MEMORY.md : «_LAYER_SIGNALment »). preview≠next: l’assemblage de l’état (done/observed/a_appliquer) a divergé parce qu’il est copié-collé entrecmd_preview(topology.py:2167-2199) etcmd_next(topology.py:2522-2562) — ce dernier manque même le gardeif "up" not in donequepreviewpossède (VRAI bug).
Côté exécution, run-phases.sh (1903 lignes) garde l’orchestration : il
décide quoi monter, enchaîne les ansible-playbook, gate via
kubectl, possède l’état partagé (CP, API_PORT, KUBECONFIG_LOCAL),
provisionne (phase_up, write_inventory). nestor ne fait que
l’appeler en subprocess, avec une circularité résiduelle
(bootstrap-seq :508 et ha-3cp :1650 re-rappellent Python, qui re-rappelle
bash ha-cni). Double-détention Python/bash de la vérité → divergences
récurrentes qu’aucun correctif local ne tarit.
La cible : fusionner ces représentations comme projections d’une source unique (graphe Python figé) et inverser l’exécution (un seul sens Python → bash). Ce plan procède par lots, banc d’abord, sans jamais casser le présent.
Invariants
Section intitulée « Invariants »- Banc d’abord (ADR 0034) : chaque lot est prouvé sur le banc Lima AVANT toute exécution prod. Le banc actuel reste la référence (mono-nœud local-path, ADR 0085).
- LES DEUX topologies à chaque lot. Un lot n’est « fait » que s’il marche
sur (a) le banc mono-nœud local-path
(
topologies/banc.yaml) ET (b) la prod 4 nœuds Ceph (topologies/dirqual.yaml) — banc d’abord, prod ensuite. Le graphe ADR 0096 est backend-conditionnel :local-path→storage-simple;ceph→ceph/sc/datalake. - Byte-identité
(ADR 0056 §3) : un
portage Python ne change pas le rendu (inventaire, ordre de phases,
périmètre de rollback) — prouvé par test, pas postulé. Piège connu : le
tie-break lexicographique de
topo_sort(rollback-lib.sh:537, clé%s%03d, comparaison\<bash) à reproduire à l’octet viarollback.batsrejoué en pytest. - Coexistence sans régression. Aucun lot ne casse le précédent ; le bash et le Python coexistent pendant la transition — on ne bascule un câblage qu’avec la preuve en main (portage à côté d’abord, bascule ensuite).
- Corriger le code, pas l’état
(ADR 0046) : tout passe
par les modules/chemins nommés ; idempotence prouvée par rejeu
changed=0(ADR 0052). - Garde d’isolation = invariant de boucle
(ADR 0053) : le
moteur Python bouclant par phase traverse
_assert_bench_target/_assert_inventory_safeà CHAQUE itération (+ échappatoireKUBECONFIGassumée) — sinon un montage banc avecKUBECONFIGprod taperait la prod.
Honnêteté : l’étape 1 est déblocable maintenant (fix pur, sans ADR). Les lots 2-9 sont gelés jusqu’à 0096/0097
Accepted(invariant documentaire, ADR 0057 §6). Chaque lot cible est re-prouvé par un run banc puis prod (invariants 1-2).
⭐ Étape 1 — Factoriser le calcul d’état partagé preview/next/up (PREMIER PAS, sans ADR)
Section intitulée « ⭐ Étape 1 — Factoriser le calcul d’état partagé preview/next/up (PREMIER PAS, sans ADR) »La divergence preview ≠ next vient de deux blocs copiés-collés qui
recalculent done/observed/a_appliquer :
-
cmd_preview(topology.py:2167-2199) :done -= {"up","bootstrap"} - observed_socle;a_appliquer -= observed_socle; soustrait_observed_layers. -
cmd_next(topology.py:2522-2562) : même logique recopiée, recalcul séparé deobserved_socle/observed_layers— et il manque le gardeif "up" not in donequepreviewpossède (VRAI bug, classe de bug, pas instance). -
Faire : extraire une fonction PURE unique
compute_plan_state(topo, seq, target, runs, now, runtime_probe) -> PlanState(done, observed, a_appliquer, freshness)dansnestor/plan.py(à côté deinstallable_now/expected_phase_sequence), appelée par les TROIS commandes (cmd_preview,cmd_next,cmd_up). Corrige la classe de bug + le garde manquant. ~30-50 lignes. Application directe de l’enseignement MEMORY.md : «nextetpreviewdoivent rendre le même verdict ; soustrairedone | observed, pas l’historique seul ». -
WIP à reprendre : un correctif partiel est stashé sur la branche
fix/preview-fidelite-reel— il corrigepreviewet le signaldataops → marquez, mais pasnext(les deux blocs sont indépendants). L’intégrer ici (la factorisationnextest le morceau manquant). -
Preuve (SANS cluster) :
tests/test_plan.py(pytest pur) prouvepreview == next == up(même verdict), mono-couche par mono-couche.ruff check .+ruff format --check .(CI globale, MEMORY.md). Risque faible, isolé.
Lot 2 — Graphe figé à côté du bash (gelé jusqu’à 0096 Accepted)
Section intitulée « Lot 2 — Graphe figé à côté du bash (gelé jusqu’à 0096 Accepted) »- Faire : porter
rollback-lib.sh(partie pure ~600 l., l. 20-665 : graphe + les 4 dimensions de périmètre namespace/targeted/crd/nodeside) ennestor/graph.py—@dataclass(frozen=True) Component(cf. ADR 0096 §1) + projections pures (topo_sort,phase_closure,phase_of_component,phase_deps,PHASE_COMPONENTS). Le bash reste, on ne bascule rien. - Preuve : pytest rejouant
bench/unit/rollback.bats→ byte-identité prouvée (invariant 3 : tie-break%s%03d, comparaison\<reproduite à l’octet). Pur, sans cluster.
Lot 3 — Éliminer les 2 ponts subprocess bash (gelé)
Section intitulée « Lot 3 — Éliminer les 2 ponts subprocess bash (gelé) »- Faire : basculer
nestor/layers.py:91_rb(et:144/:172/:178/:211/:238) ainsi quenestor/roundtrip.py_rollback_lib_callsurgraph.py.rollback-lib.shne garde plus que l’orchestration kubectl/ssh (l. 718+). - Preuve (test sens-unique) : plus aucun
subprocess(bash … rollback-lib.sh)dansnestor/. Run banc (séquence/rollback inchangés) puis prod.
Lot 4 — Intégrer le signal + aligner dataops → marquez (gelé)
Section intitulée « Lot 4 — Intégrer le signal + aligner dataops → marquez (gelé) »- Faire :
_LAYER_SIGNALdevientphase.signal_component(donnée humaine portée par le graphe — « est une feuille » ne tranche pas quanddataopsa deux feuilles) ; corrigertopology.py:751dagster-dagster-webserver→ cible marquez (même lot, sinon le signal continue de mentir). - Preuve : le verdict
dataopsreflète Marquez ; run banc puis prod.
Lot 5 — check_topology.py + lint:topology en CI + hook lefthook (gelé)
Section intitulée « Lot 5 — check_topology.py + lint:topology en CI + hook lefthook (gelé) »- Faire : créer
scripts/check_topology.pycalqué ligne à ligne surscripts/check_contract.py(Finding(level, message), fonctions pures testées,_report()exit 0/1/2). Quatre familles bloquantes (composant→rôle, rôle→composant « notifieur Marquez oublié », signal, cohérence interne). Brancherpnpm lint:topologyen CI (à côté depnpm lint:contract) et un hook lefthook régénérant / vérifiant quandbootstrap/roles/change (décision utilisateur). - Réserves à coder (sinon angles morts) : mapping rôle↔composant non 1:1
(
platform-cnpgporte 4 composants,platform-s3-bucketen porte 3) → tolérer un rôle multi-composant ET vérifier que chaque composant est référencé ; scanner lesimport_roleRÔLE→RÔLE (casplatform-s3-bucket, jamais importé par un playbook) ; gérer le multi-importplatform-build-images. - Preuve : pytest des fonctions pures (
Finding) ; le check attrape un rôle ajouté sans composant. CI verte.
Lot 6 — Moteur de chemin nestor/path.py (gelé — cœur du chantier)
Section intitulée « Lot 6 — Moteur de chemin nestor/path.py (gelé — cœur du chantier) »- Faire : créer
nestor/path.py— une boucle Python surexpected_phase_sequence(nestor/plan.py:206) →runner.launch_phase_idempotent(nestor/runner.py:176) +_wait_layer_healthy(topology.py:823), traversant_assert_bench_target/_assert_inventory_safeà CHAQUE phase (invariant 6). Généralise le patron éprouvé 2× (bootstrap.py:102+ha.py).cmd_up/cmd_nextn’appellent plussubprocess([bash, run-phases.sh])(topology.py:2349, 2406). - Réserve (état partagé) :
path.pydoit POSSÉDERCP(=:83),API_PORT(=6443,:90),KUBECONFIG_LOCAL(:146),REPO, et absorberphase_up(provisioning VM) +write_inventory— chantier plus large que « portercni.sh». - Preuve : run banc from-scratch puis prod, idempotence
changed=0; grep sens-uniquegrep -rn 'uv run python\|topology.py' bench/lima/rend 0 (saufha-cni, allowlisté jusqu’au lot 9).
Lot 7 — Porter les phases (gelé)
Section intitulée « Lot 7 — Porter les phases (gelé) »- Faire : porter d’abord les ~12 phases plateforme triviales
(
run_ansible_phase <playbook>) ; PUIS, explicitement, les harnais e2e que_wait_layer_healthyne couvre pas :dataops_chain_emit_and_verify(~62 l. : Job émetteur OpenLineage + poll + delta Marquez) etdataops_egress_internet_check(preuve NetworkPolicy egress 443). - Preuve : chaque phase prouvée isolément (
nestor next <phase>) au banc puis prod ; les harnais e2e re-prouvés (pas supposés triviaux).
Lot 8 — Paramétrage 100 % YAML + nestor/seed.py (gelé — EXIGENCE UTILISATEUR)
Section intitulée « Lot 8 — Paramétrage 100 % YAML + nestor/seed.py (gelé — EXIGENCE UTILISATEUR) »- Faire (env → YAML) : supprimer les ~40 variables d’environnement
(
CEPH_BLOCK_DEVICE,CEPH_HDD_GLOB,HA_VIP,HA_VIP_IFACE,CILIUM_CLUSTER_*,GITEA_ORG_*,GITEA_NS,EXPECTED_CLUSTER,BANC_JETABLE,HARDENING_TAGS,ATLAS_REPO_DIR,CITATION_*,PORTAL_*,SEUIL_JOURS…) au profit du YAML de topologie (topologies/*.yamlporte déjàcatalog/nodes/storage/layers/target_kind/kubeconfig).nestorlit la config du YAML, plus de l’env. Exception :KUBECONFIG(sémantique d’override « intention explicite assumée », documentée). - Faire (commande
env) : SUPPRIMERnestor env(elle imprimaitexport KUBECONFIG=<banc>àeval— l’incarnation du paramétrage-par-env). À la place,nestormaintient des contextes nommés dans~/.kube/config(banc,dirqual…) dérivés du champkubeconfigdu YAML ; l’opérateur faitkubectl --context <topo>(mécanisme standard k8s, zéro env). Les autres annexes restent (access/scale/discover/refresh/artifact/test). Mettre à jour le menu d’aide + retirerbench/lima/env.sh. - Faire (seed) : porter
nestor/seed.py—gitea-init.sh(207 l.) +seed-app-of-apps.sh(595 l.). Garder les DEUX gardes opposés :_assert_bench_target(banc) vsassert_prod_target(prod, défaut~/.kube/<topologie>.config). Un module mal gardé taperait dirqual. - Preuve : montage piloté uniquement par le YAML (aucune var d’env hors
KUBECONFIG) ; seed--dry-runpropre ; run banc puis prod.
Lot 9 — HA en dernier (exception nommée) (gelé)
Section intitulée « Lot 9 — HA en dernier (exception nommée) (gelé) »- Faire :
run_ha_3cp(run-phases.sh:1607) + le rappeltopology.py ha-3cp(:1650) restent une exception jusqu’à leur PR dédiée. La façade Python doit couvrirrun_cniETfetch_kubeconfig_node(2ᵉ geste dephase_ha_cni) — sinonha-cnireste appelé pour le kubeconfig (circularité résiduelle). - Allowlist : le test grep sens-unique allowliste
ha-cnijusqu’à cette PR (aujourd’hui le grep rend 508 ET 1650 ; le lot 6 retire 508, le lot 9 retire 1650). - Preuve : la circularité disparaît entièrement après ce lot. ⚠️ Le banc
3-VM est abandonné (Mac sans ressources, comme le banc Ceph) : la HA
ha-3cp(et la fusionha.py→path.py/gates.py/ha_probes.py) ne se prouve que sur prod (rebuild dirqual, cf.plan-ha-3cp-control-plane.md), jamais au banc — au banc, seule garantie = les tests unitaires. Le banc Lima reste mono-nœud local-path (phases + ressources VM + seed s’y prouvent ; pas la HA).
Stratégie de preuve
Section intitulée « Stratégie de preuve »- Étape 1 — prouvable MAINTENANT, sans cluster :
tests/test_plan.py(pytest pur) prouvepreview == next == up(même verdict, mono-couche par mono-couche) + garde manquant restauré.ruff check/ruff format --checkglobaux verts. - Lots cible (2-9) — banc d’abord, prod ensuite : chacun prouvé d’abord au
banc (run from-scratch consigné dans
bench/lima/RESULTS.md, idempotencechanged=0) PUIS sur dirqual (prod) — un lot n’est « fait » que sur les deux (invariants 1-2). La byte-identité des portages est prouvée par test (invariant 3 :rollback.bats→ pytest). - Honnêteté : le check de parité (lot 5) est NÉCESSAIRE mais PAS SUFFISANT
seul — les énumérations de phases vivent à 6 endroits
(
rollback-lib.sh,nestor/layers.py,_LAYER_SIGNAL,KNOWN_PHASES,PHASE_PLAYBOOK+labels, Ansible). Un « Marquez » retiré d’un label ou deKNOWN_PHASESpasserait sous le radar. La robustesse durable exige de FUSIONNER ces tables comme projections du graphe unique (cible des lots 2-6), pas seulement d’ajouter le check à côté. - Estimation : 8-12 PR, chacune re-prouvée par un run banc from-scratch
(puis prod) consigné
(ADR 0034 /
ADR 0052) + rejeu
changed=0.
| # | Risque | Lot | Mitigation / réserve |
|---|---|---|---|
| R-CNI | Œuf-poule CNI — la circularité EXISTE aujourd’hui | 6 / 9 | « cni.sh reste bash sans circularité » est une CIBLE ; le chemin actuel est circulaire (:508 → bootstrap-seq → run_cni → ha-cni). Ne tient qu’après le lot 6. |
| R-ÉTAT | État shell → Python sous-estimé | 6 | path.py doit posséder CP/API_PORT/KUBECONFIG_LOCAL et absorber phase_up (provisioning) + write_inventory — sinon Python re-rappelle bash, circularité. |
| R-AMPLEUR | Ampleur run-phases.sh 1903 l. — limite basse optimiste | 6-8 | path.py (~400-600 l. neuf) + seed.py (~800 l.) + démêlage bootstrap-seq/ha-cni non triviaux. « Risque faible » vaut pour les ~12 phases plateforme, pas les harnais e2e. |
| R-CADENCE | Mono-mainteneur — coût-temps réel non-LOC | tous | Chaque PR exige un run banc from-scratch consigné. Pattern éprouvé 2×, chaque phase se prouve isolément (nestor next <phase>) → risque faible, cadence lente. |
| R-GARDE | Garde d’isolation tournée une seule fois (faille ADR 0053) | 6 | Invariant de boucle (invariant 6) : _assert_bench_target traversée à chaque phase + échappatoire KUBECONFIG — sinon montage banc avec KUBECONFIG prod tape la prod. |
| R-CHECK | Check NÉCESSAIRE pas SUFFISANT (6 énumérations) | 5 | Fusionner les 6 tables comme projections du graphe (lots 2-6), pas juste un check à côté. Angles morts : redcap.yaml orphelin, multi-import platform-build-images. |
Avancement (2026-06-25) : Étape 1 + lots 2-5 mergés (PR #508, #509). Le graphe figé Python vérifié contre Ansible — le cœur de la refonte — est en place et
check_topologygarde la cohérence en CI. Restent les lots 6-9 (moteurpath.py, portage, env→YAML, HA), qui touchent le montage réel et exigent donc un run banc (ADR 0034) — une PR par lot, en session banc dédiée.
- Étape 1 —
compute_plan_stateextraite dansnestor/plan.py, appelée parcmd_preview/cmd_next/cmd_up+ gardeif "up" not in donerestauré dansnext;tests/test_plan.pyprouvepreview == next == up; WIPfix/preview-fidelite-reelintégré (incluantdataops → marquez).ruffverts. (déblocable sans ADR) - Lot 2 —
nestor/graph.pyà côté du bash ; byte-identité prouvée (rollback.bats→ pytest). (gelé jusqu’à 0096 Accepted) - Lot 3 — ponts
layers._rb+roundtrip._rollback_lib_callbasculés surgraph.py; plus aucunsubprocess(rollback-lib.sh)dansnestor/; banc + prod. (gelé) - Lot 4 —
phase.signal_component+dataops → marquezcorrigé (topology.py:751) ; banc + prod. (gelé) - Lot 5 —
check_topology.py(4 familles, scanimport_rolerôle→rôle) +pnpm lint:topologyCI + hook lefthookbootstrap/roles/. (gelé) - Lot 6 — moteur
nestor/path.pyécrit (PR #511). Reste : branchercmd_up/cmd_next(façade) + provisioning Python ; grep sens-unique → 0 (saufha-cni) ; preuve banc mono-nœud from-scratchchanged=0. - Lot 7 —
nestor/phases.pyécrit (~10 phases plateforme, gates dérivées du graphe ; harnais e2edataops_chain_emit_and_verify/egress_checkdéclarés mais STUBÉS). Reste : câblage façade + preuve banc. - Lot 8 — env → YAML écrit (
NodeResources+ blocs domaine, ~40 variables ; exceptionKUBECONFIG) ;nestor/kube_context.py(nestor envsupprimée) ;nestor/seed.py(gardes opposés banc/prod, I/O stubée). Reste : câblage provisioning/seed réels + preuve banc. - Lot 9 — fusion
ha.pyfaite (run_ha_3cp→path.py, gates →gates.py, sondes →ha_probes.py;ha.pysupprimé ; doublon résolu). ⚠️ Preuve HA sur prod uniquement (banc 3-VM abandonné) ; reste : retirer les rappelsrun-phases.sh(508/1650) au rebuild HA.