Internes de la sélection
src/select.rs et src/facts.rs portent le comportement qui compte. Les deux sont de la
logique pure sur des données qu’on leur passe, et les deux sont abondamment testés — 27 et 22
tests respectivement.
Normalisation
pub fn normalize(input: &[u8]) -> String // alphanumériques ASCII minuscules, le reste jeté
Elle prend des octets, pas un &str, volontairement : un corps de requête est constitué
d’octets arbitraires et n’a pas besoin d’être de l’UTF-8 valide. Filtrer sur les
alphanumériques ASCII contourne complètement la question — pas de validation, pas de
conversion avec perte, pas de mode d’échec.
C’est ce qui rend la correspondance indifférente au style de séparateur et à la façon dont Proxmox structure son JSON cette version-ci. C’est un test de sous-chaîne sur des octets, pas un schéma.
normalize_patternest l’autre. La normalisation ordinaire retire*et?avec le reste de la ponctuation, ce qui transforme chaque glob en littéral — en silence. Les motifs de sélecteur doivent passer parnormalize_pattern, qui les conserve.
Faits
Facts est une map étiquette → valeurs, plus la botte de foin. Trois sources, étagées du plus
structuré au moins structuré :
Les paramètres de query — parsing fait main avec décodage pourcent, plutôt que de tirer une
crate d’URL pour vingt lignes de travail. Les valeurs vont aussi dans la botte de foin,
pour qu’un document nommé d’après une MAC résolve que la MAC soit arrivée dans un corps POST
ou dans une query string. Sans cela, un GET — qui n’a aucun corps — ne pourrait jamais
correspondre par nom.
Le chemin apporte trois étiquettes synthétisées :
| Étiquette | Depuis |
|---|---|
path | le chemin entier, débarrassé de ses slashes |
file | son dernier segment |
segment | chaque segment, comme valeurs séparées |
file n’est pas de la décoration : la source de données NoCloud de cloud-init récupère
user-data et meta-data depuis une seule URL et ignore complètement la source si l’un
des deux manque, donc le même serveur doit leur répondre différemment. Les segments de chemin
alimentent aussi la botte de foin, parce que NoCloud peut développer
__dmi.chassis-serial-number__ dans l’URL.
Le corps JSON, aplati par flatten() en à la fois ses chemins pointés complets et ses
noms de feuilles nus. Les indices de tableau font partie du chemin mais pas du nom de feuille,
donc network_interfaces.0.mac est aussi atteignable par mac tout court.
L’écart avec la règle d’origine
La règle initiale était que le corps de requête n’est jamais parsé comme du JSON. Elle a été assouplie, délibérément et étroitement :
if let Ok(value) = serde_json::from_slice::<serde_json::Value>(body) {
flatten(&value, &mut String::new(), &mut facts);
}
Non typé, opportuniste, et non fatal — un corps qui n’est pas du JSON apporte simplement la botte de foin et rien de plus. Aucune struct n’est dérivée, donc aucune hypothèse sur le schéma de Proxmox n’est gravée dans un type.
C’est la forme « nom de feuille » qui justifie tout cela. La documentation de Proxmox
avertit elle-même que le contenu de dmi « peut varier énormément selon le système ». Un
numéro de série ne peut être atteint d’aucune autre façon, parce que l’URL gravée dans une ISO
est la même pour toutes les machines. Un sélecteur disant « un champ nommé serial, où qu’il
se trouve » survit à une réorganisation qu’un chemin figé ne supporterait pas.
Scoring
const IDENTITY_SCORE: u32 = 1_000;
fn score(control: &Control, identity: &[String], facts: &Facts) -> Option<u32> {
if identity.iter().any(|n| !n.is_empty() && facts.haystack().contains(n)) {
return Some(IDENTITY_SCORE); // nommer une machine est le plus spécifique possible
}
if control.matchers.is_empty() { return None; }
control.matchers.iter()
.all(|(k, p)| facts.matches(k, p))
.then_some(control.matchers.len() as u32)
}
identityest le radical normalisé pour un document machine, et lesmembersnormalisés pour un groupe.- Tous les critères doivent tenir ; le score est leur nombre.
IDENTITY_SCOREvaut 1000 plutôt queu32::MAXpour que « une correspondance d’identité bat n’importe quel sélecteur » reste lisible, et qu’un sélecteur à mille critères reste un problème théorique plutôt qu’un problème subtil.
Les égalités se départagent sur le nom trié, le premier par ordre alphabétique :
.max_by(|(a, ca), (b, cb)| a.cmp(b).then_with(|| cb.id.cmp(&ca.id)))
La comparaison interne inversée est ce qui fait préférer à max_by le plus petit nom.
matchbox, l’antécédent le plus proche, documente que sa propre résolution entre groupes
concurrents « ne sera pas déterministe ». Celle-ci l’est, et un test l’épingle.
Filtrage par format
fn wanted(facts: &Facts) -> Option<&'static [&'static str]> // depuis les faits `segment`
fn acceptable(wanted: Option<&…>, format: &str) -> bool // None ⇒ tout peut répondre
Le filtrage porte sur l’extension, jamais sur la famille. .ks et .preseed sont tous
deux Kind::Text ; filtrer par famille laisserait un preseed répondre à /rhel/ks.
None — une URL ne nommant aucun alias — ne contraint rien, ce qui garde /answer fonctionnel
pour un déploiement qui ne sert jamais qu’un format.
Le cache du listing
struct Cached { version: Version, loaded_at: Instant, listing: Arc<Listing> }
Réutilisé seulement quand la version() du store est inchangée, vaut Some, et que moins
de RELOAD_BACKSTOP (1 s) s’est écoulé.
La lecture littérale de la spécification — relire le répertoire à chaque requête — c’est un
readdir plus un tri plus une passe de normalisation par requête. Avec un document de réponse
par machine, le débit s’effondre :
| Documents | Relecture littérale | Cache par mtime |
|---|---|---|
| 10 | 11 954 req/s | 12 922 req/s |
| 200 | 3 198 req/s | 12 890 req/s |
| 2 000 | 311 req/s | 12 520 req/s |
| 10 000 | — | 6 924 req/s |
Un stat remplace tout le parcours, et une nouvelle machine est quand même prise en compte
sans redémarrage — ce qui est la garantie que la spécification voulait réellement. Les
identités normalisées sont calculées une fois par lecture du store, pas une fois par requête.
Le filet n’est pas redondant, et il travaille plus qu’avant. Éditer le contenu d’un document ne bouge aucun mtime de répertoire ; en ajouter un à l’intérieur du répertoire d’une machine non plus, puisque c’est un niveau sous le mtime surveillé ; et un changement fait par un autre processus ne bouge aucun atomique en mémoire. Avec un répertoire par identité, le filet est donc ce qui rattrape tout sauf l’apparition ou la disparition d’une identité. Des tests couvrent chaque cas.
Les chiffres ci-dessus ont été mesurés contre l’agencement plat. La lecture elle-même est désormais un
readdirpar identité en plus du fichier qu’elle ouvrait déjà — de 28 ms à 63 ms à 2 000 machines — que le cache amortit sur une seconde de requêtes, et qui n’a pas déplacé le débit de façon mesurable. Cela reste la raison pour laquelle un groupe vaut mieux qu’un répertoire par machine.
Le coût restant à 10 000 documents est un balayage linéaire d’aiguilles précalculées — du CPU pur, aucun appel système. Regrouper les aiguilles par longueur et faire glisser une fenêtre sur le corps supprimerait ce coût, mais un déploiement de 10 000 machines se termine déjà en moins de deux secondes. Mesurez avant d’ajouter cela.
Construire un Listing
build(snapshot) fait tout ce qui est coûteux, une fois :
- parser chaque document, en gardant l’erreur plutôt qu’en faisant échouer le chargement ;
- normaliser chaque radical et chaque entrée de
members; - résoudre les chaînes
extends, en détectant cycles et parents manquants — le groupe cassé est écarté plutôt qu’appliqué à moitié, et le problème est enregistré ; - pré-fusionner la chaîne de chaque groupe, et la pré-rendre comme chaîne de caractères quand elle ne porte aucun placeholder.
Ce dernier point est pourquoi le groupement est le chemin rapide : le cas courant en
datacenter ne parse rien par requête. Group::has_placeholders est le drapeau qui en décide.
problems est collecté ici, pas au moment de la requête, ce qui permet au
garde-fou d’annulation de l’API
d’administration d’attraper un extends cassé avant que quiconque ne le demande.
Résolution
resolve() est un match sur (machine, machine_doc, group) :
| Cas | Comportement |
|---|---|
| groupe seul | servir la chaîne préparée, ou cloner-remplir-nettoyer-rendre si templatisé |
| machine seule | remplir, nettoyer, rendre |
| les deux | chaîne de groupes, fusionner la machine par-dessus, remplir, nettoyer, rendre |
| aucun | retomber sur default pour le format demandé, qui peut lui-même extends un groupe |
Les variables de template sont les faits de la requête plus machine et group, que les
faits ne peuvent pas porter parce qu’ils ne sont connus qu’une fois la correspondance faite.
machinen’est lié que si un document machine a matché. Une machine revendiquée par lesmembersd’un groupe sans document à elle se résout avecmachine: None, donc{{ machine }}dans un groupe échoue pour exactement les membres qu’il devait servir. Le guide de templating dit d’utiliser un fait de requête à la place.
rescriptum