Système de modules v2 — squelettes .mli (design, hors build)
Passe de design pilotée par le corpus Coturnix (~/Coturnix/ng/deployment).
Ces .mli sont des contrats sans implémentation : ils fixent le vocabulaire
d’options typées avant de traduire quoi que ce soit. À promouvoir dans
elpe/lib/module/ (avec leur .ml) quand l’implémentation démarre. Hors du
build exprès — elpe/lib utilise (include_subdirs unqualified), donc un
.mli orphelin là-bas casserait dune.
Décisions de design actées (avec PE)
.mli-first, corpus = spec. On ne traduit pas service par service ; on dérive le vocabulaire des 5 vrais modules (etcd/postgres/patroni/grafana/ outline), qui restent en réserve comme cibles de dogfooding. C’est le §3.7-b du roadmap (le.mli= déclaration d’options, la compilation = vérif de type + merge).- Pas de free-form. Le
settings = {…}de Nix (RFC 42) n’existe que faute de types ; en OCaml on déclare le record. Tout à son domaine exact. - Espaces de clés ouverts (GUCs, tuning etcd) = objet de champs
optionnels. Une méthode par clé touchée,
None= défaut. Ni string (typo silencieuse), ni ADT-de-clés (ne type pas la valeur), ni 300 champs d’avance. Ajouter une clé = un champ typé ; le compilateur est le filet. - Modules de 2ᵉ ordre. Un module peut être fonction de l’ensemble évalué
(
siblings : module_ list), pas que de ses options — forcé par le générateur de dashboards Grafana qui lit les autres services. - Observabilité inversée. Au lieu du reniflage
RUST_LOG/User=coturnix, chaque service contribue sa supervision via un mixinmonitored_servicetypé ; Grafana folde les contributions. Ferme la cause d’INC-003. - Secrets = références, déchiffrées au premier boot.
secret_ref(owner/group/mode), jamais le plaintext ; le store reste pur.
Fichiers
| Fichier | Rôle |
|---|---|
option_types.mli | Domaines de valeurs : path/port/size/duration/url/ip/secret_ref. Constructeurs intelligents validants. |
module_v2.mli | Le module_ étendu (porteur unique de toutes les contributions typées) + le contrat aggregating (render avec siblings). |
observability.mli | monitored_service (mixin), types panel_spec/slo/log_selector/unit_ref, sélecteurs typés sur l’ensemble. |
etcd.mli | Test de pression minimal : binaire autonome, env-file, 1 unit, tuning-objet. |
postgresql.mli | Le foyer du vocabulaire Postgres partagé : pg_parameters (GUCs), pg_build (paquet+extensions), plus la surface propre au module (pg_hba typé, ensure). |
patroni.mli | Test de pression maximal : submodules imbriqués, secret_ref, YAML, dépendance etcd, 3 mixins ; compose Postgresql.pg_parameters/pg_build (pas de duplication). |
outline.mli | Wiki : env-file typé + secret_ref. Paquet = track Node-from-source (pas d’apt). |
grafana.mli | 1ᵉʳ ordre (grafana.ini + datasources) implémenté ; le dashboard 2ᵉ ordre lisant les frères est différé (infra module_v2). |
Statut d’implémentation (dans elpe/lib, testé pur)
| Module | Classe | État | Bloqueur boot restant |
|---|---|---|---|
| etcd | Image.Service.etcd | ✅ landé | vérif boot réel (backend) |
| postgres | Image.Service.postgresql | ✅ implémenté | vérif boot réel |
| patroni | Image.Service.patroni | ✅ implémenté | provider de secrets ([[elpe-secrets-provider]]) |
| outline | Image.Service.outline | ✅ config | sourcing Node-from-source + secrets |
| grafana | Image.Service.grafana | ✅ 1ᵉʳ et 2ᵉ ordre | sourcing apt tiers (dashboards générés OK) |
Infra 2ᵉ ordre landée : module_ porte désormais secrets + les 4 champs
d’observabilité (monitored_units/dashboards/slos/log_labels) ; snapshot
les lit ; Image.build_phase fait une 2ᵉ passe qui appelle #aggregate ~ctx
sur chaque service ; grafana#aggregate folde les frères monitorés en un
dashboard « Services maison » généré (JSON + provider). Vocabulaire typé dans
elpe/lib/module/observability.{ml,mli} ; sélecteurs dans Image_service.Select.
Le mixin monitored_service a été abandonné au profit de champs sur la classe
de base service (donnée pure à défaut vide, comme packages/files).
Vocabulaire commun : Option_types (path/port/size/duration/url/ip/endpoint/secret_ref). Les GUCs postgres (pg_parameters) sont l’objet-à-champs-optionnels partagé postgres⇄patroni.
Ce que le vocabulaire couvre (validé au niveau des types)
- etcd : intégralement exprimable (
option_types+systemd_service). - patroni : submodules + GUCs + secrets + dépendance etcd — le vocabulaire tient. Un seul point réel remonté : la valeur « paquet avec extensions ».
- Grafana : la forme 2ᵉ ordre + observabilité inversée le couvre sans reniflage.
Questions ouvertes (à trancher avant/pendant l’implémentation)
-
packageavec extensions. Nix bundle base+extensions en un store path (withPackages) ; Elpe n’a qu’un nom de deb (string).pg_build(danspostgresql.mli, partagé avec patroni) tient pour Postgres, mais faut-il une valeurpackagetypée de 1ʳᵉ classe et générique ?Note : décision actée —
pg_parameters/pg_buildvivent dansPostgresql(un config postgres = ce que patroni bootstrappe), patroni les compose. Les modules partagent les types d’options, pas le service. -
module_grossit. ✅ tranché & landé :secrets+ les 4 champs d’observabilité sont des champs du normal form (cohérent avec « activations = porteur unique »). Toute contribution typée devient un champ demodule_. -
Ordre entre modules de 2ᵉ ordre. v1 n’a qu’un niveau (Grafana rend en dernier ;
#aggregateest appelé sur tous les services, les non-agrégeants renvoient[]). Un vrai ordre de dépendance reste hors périmètre. -
Injecteur de secrets — chaque boot. ✅ landé. Analogue typé d’agenix : chaque secret est un
<name>.agechiffré vers la clé SSH publique de l’hôte, déchiffré parage(1)(paquet Ubuntuage) contre/etc/ssh/ssh_host_ed25519_key— la clé qu’openssh génère déjà (ssh-keygen -A), donc aucune clé à créer/gérer. Placement vers le tmpfs/run/elpe-secrets/<name>avec owner/group/mode.Chaque boot, pas 1ᵉʳ boot.
/runest un tmpfs vidé à chaque reboot : le déchiffrement est un serviceType=oneshot RemainAfterExit=yesqui re-tourne à chaque boot (Image_service.boot_service),After=les clés d’hôte etBefore=ses consommateurs — unConditionFirstBootlaisserait les secrets absents après le 1ᵉʳ reboot.secrets_decrypt_scriptfactorise la boucleage.Consommation patroni (décidée). Patroni lit
${PATRONI_*}dans son YAML → il faut ces vars dans l’env du process. Choix : unEnvironmentFilevia un drop-inpatroni.service.d/10-elpe-secrets.conf. Le service de secrets de patroni (elpe-secrets-patroni.service) déchiffre puis écrit/run/elpe-secrets/patroni.env(root:root 0600) depuissecret_env(mapping var→secret, connu seulement de patroni) ; systemd le lit en root avant de tomber à l’userpostgres, donc le process reçoit les vars sans quepostgrespuisse lire le fichier.LoadCredentialne convient pas ici (patroni ne lit pas$CREDENTIALS_DIRECTORY) mais reste le bon mécanisme pour un futur service écrit viaSystemd_unit.Côté entrée (build, pas déploiement). Le ciphertext est public, donc cuit dans l’image à
/etc/elpe/secrets/<name>.ageparsecrets_ciphertext_files— l’analogue deage.secrets.<name>.filed’agenix. L’auteur de config lit le.agecommité à l’eval (seamhost_read_source, armor ASCIIage -apour survivre au heredoc). Même mécanisme cloud et local, seuls diffèrent la machine qui déclare le secret et la clé d’hôte qui le chiffre : un secret d’app cloud (mot de passe patroni) se consomme via l’EnvironmentFileci-dessus ; une clé locale (wireguard, par-machine) se lit directement àsecret_path, sans env-file. ✅ Le chemin local est câblé et testé :Image_service.wireguard_filesémet les.netdev/.networksystemd-networkd (PrivateKeyFile=<secret_path>, jamais la clé inline) + le service de secretsBefore=systemd-networkd.service.Outil de (re-)chiffrement.
scripts/elpe-secrets-rekey— l’analogue dusecrets.nixd’agenix : un fichiersecrets.recipients(par secret : les pubkeys d’hôtes + une clé break-glass) etencrypt/rekey/rekey-allenrobentage -R. Jamais de clé privée d’hôte ni de store ; le plaintext vient du coffre via stdin. Reste (opérationnel) : lancerencrypt/rekeysur les 41 secrets dengavec les vraies pubkeys cibles.Clé d’hôte vs clé PGP dédiée. La clé SSH d’hôte l’emporte : elle existe déjà,
agela prend nativement comme identité, et une hiérarchie PGP parallèle n’apporterait rien. Coffres (Proton Pass, …). La clé d’hôte n’est que le canal de livraison ; la source de vérité du plaintext reste le coffre. Au déploiement, un humain (ou la CI) avec accès au coffre faitage -R <recipients> -o <name>.ageet ne commite que le chiffré.recipients= la clé SSH publique de chaque hôte cible + une clé break-glass d’org pour la récupération (façonsecrets.nixd’agenix).
Tracks de sourcing (orthogonaux au système de modules)
- Node-from-source (
Node.build_bundle) → débloque outline le paquet ; sœur de la chaîne Python existante (build_wheel_of). Le lockfile npm porte déjà l’integritysha256, pin plus propre que PyPI. - Dépôts apt tiers (grafana, timescaledb) → apprendre à Elpe à locker un dépôt hors-Ubuntu.
Aucun ne bloque le design ci-dessus ni etcd.