rescriptum · an answer written for this machine
FR
Sur cette page

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_pattern est 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 par normalize_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 :

ÉtiquetteDepuis
pathle chemin entier, débarrassé de ses slashes
fileson dernier segment
segmentchaque 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)
}
  • identity est le radical normalisé pour un document machine, et les members normalisés pour un groupe.
  • Tous les critères doivent tenir ; le score est leur nombre.
  • IDENTITY_SCORE vaut 1000 plutôt que u32::MAX pour 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 :

DocumentsRelecture littéraleCache par mtime
1011 954 req/s12 922 req/s
2003 198 req/s12 890 req/s
2 000311 req/s12 520 req/s
10 0006 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 readdir par 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) :

CasComportement
groupe seulservir la chaîne préparée, ou cloner-remplir-nettoyer-rendre si templatisé
machine seuleremplir, nettoyer, rendre
les deuxchaîne de groupes, fusionner la machine par-dessus, remplir, nettoyer, rendre
aucunretomber 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.

machine n’est lié que si un document machine a matché. Une machine revendiquée par les members d’un groupe sans document à elle se résout avec machine: 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.

Mis à jour le Modifier cette page