Fork channel

Create a new channel as a copy of main.

Rename channel

Rename main to:

Delete channel

Delete main? This cannot be undone.

mode name
-rw-r--r-- README.md
-rw-r--r-- etcd.mli
-rw-r--r-- grafana.mli
-rw-r--r-- module_v2.mli
-rw-r--r-- option_types.mli
-rw-r--r-- outline.mli
-rw-r--r-- patroni.mli
-rw-r--r-- postgresql.mli
README

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)

  1. .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).
  2. 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.
  3. 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.
  4. 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.
  5. Observabilité inversée. Au lieu du reniflage RUST_LOG/User=coturnix, chaque service contribue sa supervision via un mixin monitored_service typé ; Grafana folde les contributions. Ferme la cause d’INC-003.
  6. Secrets = références, déchiffrées au premier boot. secret_ref (owner/group/mode), jamais le plaintext ; le store reste pur.

Fichiers

FichierRôle
option_types.mliDomaines de valeurs : path/port/size/duration/url/ip/secret_ref. Constructeurs intelligents validants.
module_v2.mliLe module_ étendu (porteur unique de toutes les contributions typées) + le contrat aggregating (render avec siblings).
observability.mlimonitored_service (mixin), types panel_spec/slo/log_selector/unit_ref, sélecteurs typés sur l’ensemble.
etcd.mliTest de pression minimal : binaire autonome, env-file, 1 unit, tuning-objet.
postgresql.mliLe foyer du vocabulaire Postgres partagé : pg_parameters (GUCs), pg_build (paquet+extensions), plus la surface propre au module (pg_hba typé, ensure).
patroni.mliTest de pression maximal : submodules imbriqués, secret_ref, YAML, dépendance etcd, 3 mixins ; compose Postgresql.pg_parameters/pg_build (pas de duplication).
outline.mliWiki : env-file typé + secret_ref. Paquet = track Node-from-source (pas d’apt).
grafana.mli1ᵉʳ 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)

ModuleClasseÉtatBloqueur boot restant
etcdImage.Service.etcd✅ landévérif boot réel (backend)
postgresImage.Service.postgresql✅ implémentévérif boot réel
patroniImage.Service.patroni✅ implémentéprovider de secrets ([[elpe-secrets-provider]])
outlineImage.Service.outline✅ configsourcing Node-from-source + secrets
grafanaImage.Service.grafana✅ 1ᵉʳ et 2ᵉ ordresourcing 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)

  1. package avec extensions. Nix bundle base+extensions en un store path (withPackages) ; Elpe n’a qu’un nom de deb (string). pg_build (dans postgresql.mli, partagé avec patroni) tient pour Postgres, mais faut-il une valeur package typée de 1ʳᵉ classe et générique ?

    Note : décision actée — pg_parameters/pg_build vivent dans Postgresql (un config postgres = ce que patroni bootstrappe), patroni les compose. Les modules partagent les types d’options, pas le service.

  2. 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 de module_.

  3. Ordre entre modules de 2ᵉ ordre. v1 n’a qu’un niveau (Grafana rend en dernier ; #aggregate est appelé sur tous les services, les non-agrégeants renvoient []). Un vrai ordre de dépendance reste hors périmètre.

  4. Injecteur de secrets — chaque boot. ✅ landé. Analogue typé d’agenix : chaque secret est un <name>.age chiffré vers la clé SSH publique de l’hôte, déchiffré par age(1) (paquet Ubuntu age) 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. /run est un tmpfs vidé à chaque reboot : le déchiffrement est un service Type=oneshot RemainAfterExit=yes qui re-tourne à chaque boot (Image_service.boot_service), After= les clés d’hôte et Before= ses consommateurs — un ConditionFirstBoot laisserait les secrets absents après le 1ᵉʳ reboot. secrets_decrypt_script factorise la boucle age.

    Consommation patroni (décidée). Patroni lit ${PATRONI_*} dans son YAML → il faut ces vars dans l’env du process. Choix : un EnvironmentFile via un drop-in patroni.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) depuis secret_env (mapping var→secret, connu seulement de patroni) ; systemd le lit en root avant de tomber à l’user postgres, donc le process reçoit les vars sans que postgres puisse lire le fichier. LoadCredential ne convient pas ici (patroni ne lit pas $CREDENTIALS_DIRECTORY) mais reste le bon mécanisme pour un futur service écrit via Systemd_unit.

    Côté entrée (build, pas déploiement). Le ciphertext est public, donc cuit dans l’image à /etc/elpe/secrets/<name>.age par secrets_ciphertext_files — l’analogue de age.secrets.<name>.file d’agenix. L’auteur de config lit le .age commité à l’eval (seam host_read_source, armor ASCII age -a pour 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’EnvironmentFile ci-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/.network systemd-networkd (PrivateKeyFile=<secret_path>, jamais la clé inline) + le service de secrets Before=systemd-networkd.service.

    Outil de (re-)chiffrement. scripts/elpe-secrets-rekey — l’analogue du secrets.nix d’agenix : un fichier secrets.recipients (par secret : les pubkeys d’hôtes + une clé break-glass) et encrypt/rekey/rekey-all enrobent age -R. Jamais de clé privée d’hôte ni de store ; le plaintext vient du coffre via stdin. Reste (opérationnel) : lancer encrypt/rekey sur les 41 secrets de ng avec 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à, age la 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 fait age -R <recipients> -o <name>.age et 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çon secrets.nix d’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’integrity sha256, 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.