Architecture
Un processus, une crate, aucun framework. main.rs est un binaire mince par-dessus lib.rs,
pour que chaque comportement soit testable directement plutôt qu’à travers une socket.
La forme de l’ensemble
flowchart TB
subgraph net["Réseau"]
I["Installateur<br/>POST /answer · GET /rhel/ks"]
A["Client d'administration"]
end
I --> M["main.rs<br/>accept · timeouts · routage"]
A --> AD["admin.rs<br/>listener propre · auth · écritures gardées"]
M --> F["facts.rs<br/>query · feuilles JSON · botte de foin"]
F --> S["select.rs<br/>match · superposer · remplir"]
AD --> S
S --> FM["format/<br/>parser · fusionner · rendre"]
FM --> MG["merge.rs<br/>fusion profonde TOML"]
FM --> X["format/xml.rs<br/>arbre XML"]
S --> ST["store/ (trait)"]
AD --> ST
ST --> FS["file.rs<br/>un répertoire"]
ST --> SQ["sqlite.rs<br/>une base"]
CLI["cli.rs<br/>render · check · import · export"] --> S
Ce que possède chaque pièce
| Module | Possède |
|---|---|
main.rs | le runtime tokio, la boucle d’accept, le sémaphore de connexions, les deux timeouts, le routage, la vérification du jeton de réponse, et l’appel spawn_blocking qui fait la recherche |
facts.rs | transformer une requête en valeurs étiquetées — paramètres de query, corps JSON aplati, segments de chemin, et la botte de foin normalisée |
select.rs | le comportement qui compte : normalisation, scoring, chaîne de groupes, ordre de fusion, remplissage de templates, et le listing mis en cache |
format/ | une interface par format de document. Doc parse, fusionne, rend, et signale ses clés de contrôle |
merge.rs | la fusion profonde TOML, utilisée par format |
store/ | d’où viennent les documents, derrière un trait de lecture à deux méthodes |
admin.rs | son propre listener, l’auth bearer, le garde-fou d’échecs, et l’annulation qui empêche une écriture de casser le jeu de réponses |
config.rs | l’environnement, et la validation qui transforme une configuration dangereuse en erreur de démarrage |
envfile.rs | le fichier que nomme RESCRIPTUM_ENV_FILE : parsé, jamais découvert, et fatal s’il est illisible |
cli.rs | render, check, import, export |
capture.rs | l’enregistrement des corps de requête |
log.rs | une ligne par événement, des horodatages UTC calculés sans crate de date, et les deux réglages au-dessus : ce qui est gardé, et où cela va |
La seule frontière qui mérite d’être défendue
Le store est délibérément mince. Il rend le texte brut des documents et un jeton de version bon marché, et ne décide de rien :
pub trait Store: Send + Sync {
fn version(&self) -> Version; // assez peu cher pour être appelé par requête
fn snapshot(&self) -> io::Result<Snapshot>; // seulement quand la version a bougé
fn describe(&self) -> String;
}
Chaque décision — correspondance, chaînes extends, fusion, rendu, check — vit au-dessus,
dans select.rs et merge.rs, et est partagée par les deux backends. Dès qu’un backend se
met à décider du comportement, les deux divergent.
tests/stores.rs est ce qui fait de cela une garantie plutôt qu’une intention : chaque cas de
comportement tourne deux fois, une par store, et affirme le résultat identique.
La moitié écriture est un trait séparé, parce que servir des réponses n’en a jamais besoin :
pub trait StoreWrite: Store {
fn put_machine(&self, id: &str, format: &str, body: &str) -> io::Result<()>;
fn delete_machine(&self, id: &str, format: &str) -> io::Result<bool>;
// …groupes, défaut
}
Notez que chaque opération nomme un format. Un document est indexé par ce à quoi il sert — une machine et un système d’exploitation — pas par identifiant seul.
La couche de cache
Answers enveloppe un store et garde un Listing parsé et fusionné derrière un mutex :
struct Cached { version: Version, loaded_at: Instant, listing: Arc<Listing> }
Une requête réutilise le cache seulement quand les trois conditions tiennent :
store.version()est inchangée — pour les fichiers, le mtime du répertoire ; pour SQLite, un atomique en mémoire ;- cette version est
Some— une version illisible n’est jamais traitée comme « inchangée » ; - moins de
RELOAD_BACKSTOP(1 s) s’est écoulé.
Le filet n’est pas redondant avec la vérification de version. Éditer le contenu d’un fichier de groupe ne bouge aucun mtime de répertoire, et un changement fait par un autre processus ne bouge aucun atomique en mémoire. Sans le filet, l’une ou l’autre édition serait invisible jusqu’à ce qu’autre chose arrive au répertoire.
Un mutex empoisonné — une autre requête a paniqué en plein rafraîchissement — est récupéré plutôt que propagé. Les données en cache sont encore structurellement saines, et faire échouer une installation à cause du panic d’une autre requête serait le mauvais compromis.
Pourquoi il n’y a pas de framework
Le routage ici est un seul if sur la méthode et le chemin. Un framework n’apporte rien
pour ça, et axum en particulier ne donne aucun moyen de définir un délai de lecture des
en-têtes — précisément le garde-fou anti-slowloris qui a motivé le passage à l’asynchrone.
Donc : hyper directement.
Dépendances
64 crates, 2,4 Mo statique sur ARMv7 (1,3 Mo sans SQLite). Directes :
| Crate | Pour |
|---|---|
tokio | le runtime, les timers, les signaux |
hyper + hyper-util + http-body-util | HTTP/1, avec un délai de lecture des en-têtes |
toml_edit | TOML, en préservant la mise en forme |
serde_json | documents JSON, et aplatissement d’un corps de requête |
serde_yaml_ng | documents YAML |
quick-xml | documents XML |
rusqlite (optionnelle, bundled) | le store SQLite |
Aucun serde derive nulle part. La règle d’origine était « ne jamais parser le corps de
requête comme du JSON » ; elle a été assouplie délibérément, et l’énoncé honnête de l’état
actuel est : le corps est parsé en une serde_json::Value non typée quand il se trouve
être du JSON, uniquement pour en récolter des faits. Rien n’est désérialisé dans une struct,
donc aucune hypothèse sur le schéma de Proxmox n’est gravée dans un type. Un corps qui n’est
pas du JSON n’est pas une erreur — il apporte la botte de foin et rien de plus. Voir
sélection.
Ajouter une dépendance exige une raison dans le message de commit. Ce binaire tourne en root sur le matériel d’autres gens.
rescriptum