← Retour au site Chargement…

rescriptum

Développement

an answer written for this machine

Généré le 30 août 2026

Travailler sur rescriptum

Travailler sur rescriptum

rescriptum est une chose petite et ciblée : il détermine quelle configuration d’installation revient à chaque machine, la compose à partir de couches, et la sert. Environ 4 000 lignes de Rust, 308 tests, et une courte liste de contraintes qui ne sont pas révisables à la légère.

Cet espace est le pourquoi. Le Guide est le quoi.

Le mettre en route

git clone https://github.com/z29k/rescriptum && cd rescriptum
cargo test                      # 308 tests
cargo run -- --help

Essayez un changement contre les exemples travaillés plutôt que contre les seuls tests — ils sont le seul endroit où tous les formats sont montrés en train de se composer ensemble :

RESCRIPTUM_ANSWERS_DIR=examples cargo run -- check
RESCRIPTUM_ANSWERS_DIR=examples cargo run -- render --query "path=/rhel/ks&serial=7ABC123"

Avant d’ouvrir une PR :

cargo fmt --all
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo build --release --no-default-features   # le plus petit build doit continuer de marcher

Ces quatre-là sont exactement ce que lance la CI.

Le dépôt

CheminContient
src/main.rsmise en place du runtime, boucle d’accept, service des connexions, routage, et la moitié bloquante d’une requête
src/lib.rsla crate. main.rs est un binaire mince par-dessus, pour que le comportement soit testable directement
src/select.rsnormalisation, correspondance, superposition — le comportement qui compte
src/facts.rsce qu’une requête dit de la machine
src/format/une interface par format de document ; xml.rs porte l’arbre XML
src/merge.rsla fusion TOML, utilisée par format
src/store/d’où viennent les documents : file.rs, sqlite.rs, derrière un trait mince
src/admin.rsl’API d’écriture, et la garantie qu’une écriture ne peut pas casser le parc
src/config.rsconfiguration par l’environnement
src/envfile.rsle fichier de valeurs par défaut que nomme RESCRIPTUM_ENV_FILE — jamais découvert, seulement nommé
src/capture.rsenregistrement de ce que les machines envoient réellement
src/cli.rsles sous-commandes render, check, import et export
src/log.rsune ligne par événement, des horodatages UTC sans crate de date, et les deux réglages au-dessus
tests/le vrai binaire sur une socket (integration, admin, guards), sa ligne de commande (cli), et la suite de conformité des deux stores (stores)
examples/un exemple travaillé de chaque format supporté
docs/ce site

Ne redéclarez jamais un module dans main.rs. Cela compile une seconde copie, fait tourner chaque test unitaire deux fois, et laisse les deux copies diverger.

Par où commencer à lire

  • Les contraintes — d’abord. Elles expliquent l’essentiel de la forme du code, et plusieurs ressemblent à des choses qu’on aurait envie d’« améliorer » tant qu’on ne sait pas pourquoi elles sont là.
  • Architecture — la carte des modules et ce qui circule entre eux.
  • Le cycle de vie d’une requête — une requête de l’accept à la réponse.
  • Sélection — la partie avec le plus de comportement par ligne.
  • Pièges déjà rencontrés — une liste de choses qui ont coûté du temps une fois. La lire coûte moins cher que les redécouvrir.

Conventions

  • Anglais pour le code, les commentaires, la documentation et les messages de commit. Cette documentation existe en français en plus, pas à la place.
  • Un comportement a sa place dans tests/stores.rs, qui fait tourner chaque cas contre les deux stores et exige le résultat identique. Un test couvrant un seul store prouve la moitié de ce qu’il prétend. Voir tests.
  • Les tableaux remplacent, ils ne concatènent pas, dans tous les formats.
  • Échouer bruyamment. Un groupe manquant, un template impossible à remplir, un document qui ne parse pas — tous sont des erreurs avec une raison. Servir une réponse à moitié construite installe une machine de travers, et personne ne s’en aperçoit avant qu’elle ne tourne.
  • Ajouter une dépendance exige une raison dans le message de commit. Ce binaire tourne en root sur le matériel d’autres gens, et le job audit de la CI est l’autre moitié de cette règle : une raison de l’ajouter n’est pas une raison de la garder.
  • Commits conventionnels avec un scopefeat(http): …, fix(select): ….

À lire aussi

CLAUDE.md à la racine du dépôt est le document d’architecture écrit pour les agents de code. Il recoupe largement cet espace et c’est le fichier à mettre à jour quand une contrainte change.

Architecture

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

ModulePossède
main.rsle 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.rstransformer 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.rsle 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.rsla fusion profonde TOML, utilisée par format
store/d’où viennent les documents, derrière un trait de lecture à deux méthodes
admin.rsson 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.rsl’environnement, et la validation qui transforme une configuration dangereuse en erreur de démarrage
envfile.rsle fichier que nomme RESCRIPTUM_ENV_FILE : parsé, jamais découvert, et fatal s’il est illisible
cli.rsrender, check, import, export
capture.rsl’enregistrement des corps de requête
log.rsune 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 :

  1. store.version() est inchangée — pour les fichiers, le mtime du répertoire ; pour SQLite, un atomique en mémoire ;
  2. cette version est Some — une version illisible n’est jamais traitée comme « inchangée » ;
  3. 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 :

CratePour
tokiole runtime, les timers, les signaux
hyper + hyper-util + http-body-utilHTTP/1, avec un délai de lecture des en-têtes
toml_editTOML, en préservant la mise en forme
serde_jsondocuments JSON, et aplatissement d’un corps de requête
serde_yaml_ngdocuments YAML
quick-xmldocuments 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.

Les contraintes

Les contraintes

Ce sont des décisions, pas des oublis. Plusieurs ressemblent, vues de l’extérieur, à des améliorations évidentes. N’en changez aucune sans demander — et si vous en changez une, changez cette page et CLAUDE.md avec.

Asynchrone, sur tokio et hyper

La spécification d’origine demandait zéro dépendance et un thread par connexion. Les deux ont été écartées délibérément, une fois que l’exigence est devenue « absorber une rafale de provisioning professionnelle ». Un déploiement de 2 000 machines, ce sont 2 000 connexions quasi simultanées, et un thread chacune fait 2 000 piles sur une machine à 512 Mo.

Ce qui a survécu de la spec : aucun serde derive, aucun framework, et une liste de dépendances directes très courte. Voir architecture.

hyper directement, pas axum

axum 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. Le routage ici est un seul if sur la méthode et le chemin, donc un framework n’apporte rien et coûte la seule chose qui comptait.

Concurrence bornée, même si les tâches sont bon marché

Une connexion coûte des kilo-octets plutôt qu’un thread — c’est tout l’intérêt de la réécriture asynchrone. Mais bon marché n’est pas gratuit, et un accept non borné transforme quand même une rafale en épuisement mémoire.

Un Semaphore de RESCRIPTUM_MAX_CONNECTIONS plafonne les connexions en vol. Au-delà, le serveur écrit un 503 immédiat et ferme plutôt que de mettre en file : un client à qui on dit de réessayer s’en sort mieux qu’un client garé dans une file qui ne se videra pas.

Le travail sur le système de fichiers passe par spawn_blocking

read_dir et read sont des appels bloquants, et bloquer un thread worker asynchrone bloque toutes les autres connexions que ce thread pilote. Sur un NAS dont le disque dort, ce n’est pas théorique — un réveil de disque se compte en secondes, pas en millisecondes.

resolve() porte à la fois le parsing et l’E/S, et n’est jamais appelé qu’à l’intérieur d’un spawn_blocking. Un panic là renvoie un 500 ; il ne peut pas emporter le serveur.

Ne jamais paniquer sur une entrée malformée

Tout échec de parsing devient une réponse d’erreur plus une ligne de log. Écrivez le code comme s’il n’y avait pas de filet.

Il y en a un, délibérément : le profil release ne définit pas panic = "abort". Avec le déroulement, un panic est confiné à la connexion qui l’a causé au lieu de tuer un serveur en pleine installation. Coût mesuré sur ARMv7 : +2416 octets, +0,8 %. Ne le « ré-optimisez » pas.

Si la conception passe un jour à un pool de threads, ajoutez un catch_unwind à la frontière du worker — un thread de pool qui meurt en silence est pire que l’un ou l’autre.

[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
# panic = "abort" est délibérément ABSENT

Le store ne décide de rien

Il rend le texte brut des documents et un jeton de version bon marché. Correspondance, chaînes extends, fusion, rendu et check vivent tous au-dessus et sont partagés.

Gardez-le ainsi. Dès qu’un backend se met à décider du comportement, les deux divergent — et tests/stores.rs cesse de pouvoir prouver le contraire.

L’organisation du stockage n’est pas l’URL

Répertoires et lignes de base sont un espace de recherche et doivent rester libres d’être réorganisés. Une URL est un contrat public gravé dans une ISO et ne doit pas bouger parce que quelqu’un a renommé un dossier. Une conception antérieure faisait du nom de répertoire le segment d’URL et a été écartée pour exactement cette raison.

La conséquence est que la clé d’un document est (identifiant, format), ce autour de quoi le schéma SQLite est construit.

Ne jamais construire un chemin de fichier à partir de données de requête

C’est le garde-fou contre la traversée de chemin, et il est structurel plutôt qu’une vérification : seules les entrées directes du répertoire de réponses sont lues. Les identifiants arrivant à l’API d’administration sont validés séparément, à la frontière de l’API et dans les deux stores, parce qu’export les retransforme en noms de fichiers.

Les réponses doivent être des documents valides

Avant la fusion, un fichier de réponse était servi comme des octets opaques, donc un fichier malformé atteignait l’installateur. Maintenant c’est un 500 avec l’erreur de parsing dans le log.

C’est le meilleur échec — un installateur qui reçoit du TOML à moitié valide échoue d’une façon bien plus déroutante — mais c’est un changement de comportement, et des fixtures écrites en pseudo-YAML ont cessé de fonctionner à ce moment-là.

Échouer bruyamment

Un groupe manquant, un template impossible à remplir, un document qui ne parse pas : tous sont des erreurs avec une raison, jamais une réponse au mieux.

Le raisonnement est toujours le même. Une réponse à moitié construite installe une machine de travers, et personne ne s’en aperçoit avant qu’elle ne tourne. Une installation ratée se remarque en quelques minutes.

Asymétries délibérées

Deux endroits où la symétrie évidente est fausse exprès :

Le jeton de réponse n’est jamais limité en débit ; celui d’administration siune baie peut se trouver derrière une seule adresse, donc l’exclure transforme un mauvais jeton en déploiement raté. Aucun installateur ne parle à l’API d’administration
Un jeton de réponse court avertit ; un jeton d’administration court empêche le démarragerefuser de démarrer laisserait un parc incapable de s’installer. Refuser de démarrer l’API d’administration ne coûte d’installation à personne

Ce que la spec demandait et n’a pas eu

plans/rescriptum-spec.md (dans le .gitignore, donc un contributeur ne l’aura pas) est le compte rendu de ce qui a été demandé au départ, pas une description de ce qui existe. Le projet l’a dépassée dans toutes les directions : multi-OS, sélecteurs, templating, une API d’administration, un store en base.

Trois écarts précis, tous listés ci-dessus : asynchrone plutôt qu’un thread par connexion, panic = "abort" omis, et le corps de requête parsé en JSON non typé pour en récolter des faits. Là où la spec et cette page divergent, c’est cette page qui a raison.

Le cycle de vie d'une requête

Le cycle de vie d’une requête

sequenceDiagram
  participant C as Installateur
  participant L as boucle d'accept
  participant T as tâche tokio
  participant B as pool bloquant

  C->>L: connexion TCP
  L->>L: try_acquire_owned()
  alt pas de permis
    L-->>C: 503, fermeture
  else
    L->>T: spawn(connection)
    Note over T: échéance de connexion démarre
    C->>T: en-têtes de requête
    Note over T: header_read_timeout
    T->>T: /health ? jeton ? méthode ? Content-Length ?
    C->>T: corps (plafonné à 1 Mo)
    T->>B: spawn_blocking(Facts + resolve)
    B->>B: version() → listing en cache ou snapshot()
    B->>B: match · superposer · fusionner · remplir · nettoyer
    B-->>T: Resolution | None | Err
    T-->>C: 200 + document · 404 · 500
    T->>T: une ligne de log, capture si activée
  end

1. Accept

serve() boucle sur listener.accept() dans un tokio::select! avec le signal d’arrêt (SIGTERM, ce qu’envoie le planificateur de tâches DSM, ou Ctrl-C).

Un échec d’accept — épuisement de descripteurs de fichiers, par exemple — journalise et continue. Terminer la boucle transformerait un problème de ressources passager en panne.

Un permis est pris au sémaphore avant le spawn. Sans permis, shed() écrit un 503 et ferme — répondre honnêtement plutôt que jeter en silence, pour que le client sache qu’il doit réessayer au lieu de deviner.

2. La connexion

Deux timeouts, et aucun n’est redondant :

Garde-fouCouvre
http1::Builder::header_read_timeoutun client qui ouvre une connexion et distille ses en-têtes
tokio::time::timeout autour de la connexion entièretout ce qui vient après les en-têtes

hyper n’a pas de délai de lecture de corps. Sans le second garde-fou, un client qui promet un corps dans son Content-Length puis n’envoie rien garerait une connexion indéfiniment — à l’intérieur d’un permis, donc en coûtant une place autant que de la mémoire.

hyper panique si un timeout est défini sans timer. header_read_timeout exige .timer(TokioTimer::new()). Omettez-le et chaque connexion panique à l’exécution — cela ne casse pas la compilation. Voir pièges.

3. Routage

Un seul if sur la méthode et le chemin, dans cet ordre :

  1. GET /health200 OK. Avant l’authentification, avant tout, pour que la supervision ne s’éteigne jamais.
  2. Le jeton de réponse, quand RESCRIPTUM_ANSWER_TOKEN est défini. Comparé sans retour anticipé, pour qu’un mauvais jeton ne puisse pas être récupéré octet par octet par qui chronomètre les réponses. Journalisé, jamais limité en débit.
  3. La méthode — tout ce qui n’est ni GET ni POST donne 405.
  4. Content-Length — une taille annoncée aberrante est refusée depuis l’en-tête, plutôt qu’en laissant Limited sauter après avoir tamponné un mégaoctet.
  5. Le corps, via Limited::new(…, MAX_BODY). Une erreur de limite de longueur devient 413, toute autre 400.

Il n’y a pas de routage de chemin au-delà : POST et GET sont traités sur n’importe quel chemin, parce que l’URL est gravée dans une ISO. Le chemin n’est pas ignoré — il devient des faits — il ne décide simplement pas s’il faut répondre.

4. Résolution, hors du worker asynchrone

let picked = tokio::task::spawn_blocking(move || {
    let facts = Facts::from_request(Some(&request_path), query.as_deref(), &body);
    answers.resolve(&facts)
}).await;

Les deux moitiés ont leur place hors du worker asynchrone : construire les faits est du travail CPU sur une charge de taille arbitraire, et la recherche est de l’E/S bloquante. Faire l’un ou l’autre sur un thread du runtime bloque toutes les autres connexions que ce thread pilote.

À l’intérieur, resolve() :

  1. demande au store sa version() — un stat pour les fichiers, une lecture atomique pour SQLite ;
  2. réutilise le Listing en cache, ou prend un nouveau snapshot() et le reconstruit ;
  3. choisit le meilleur document machine et le meilleur groupe (scoring) ;
  4. résout extends, à l’intérieur d’un format ;
  5. fusionne chaîne de groupes → document machine ;
  6. remplit les {{ placeholders }} ;
  7. retire les clés de contrôle ;
  8. rend.

5. Réponse

RésultatRéponse
Ok(Ok(Some(resolution)))200, le document, Content-Type selon son format, Connection: close
Ok(Ok(None))404 no answer file applies
Ok(Err(e))500, avec la raison sur la ligne de log
Err(join_error)500 answer lookup panicked — cela ne peut pas emporter le serveur, mais ne doit pas non plus passer en silence

Puis exactement une ligne de log, et une capture s’il y en a une de configurée. Le corps a été cloné avant que la résolution ne le prenne, et seulement quand la capture est active.

Le listener d’administration

Un TcpListener séparé, une tâche serve() séparée, lancée seulement quand RESCRIPTUM_ADMIN_ADDR est défini — et seulement après que Config::validate a confirmé que le store est SQLite et que le jeton est assez long. Son propre pipeline est dans les internes de l’API d’administration.

Arrêt

SIGTERM ou Ctrl-C termine la boucle d’accept et fait retourner serve(). Les connexions en vol ne sont pas drainées : il n’y a pas d’état à perdre, le client réessaie, et un serveur de provisioning qui refuse de s’arrêter est pire qu’un qui perd une requête.

Internes de la sélection

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.

Formats et fusion

Formats et fusion

src/format/mod.rs donne une seule interface à chaque format de document, pour que select.rs n’ait jamais à savoir lequel il tient.

enum Inner {
    Toml(toml_edit::DocumentMut),
    Yaml(serde_yaml_ng::Value),
    Json(serde_json::Value),
    Xml(xml::Document),
    Text(String),
}

Doc l’enveloppe et offre parse, merge, render, control, strip_control, substitute et has_placeholders. Ajouter un format, c’est ajouter une variante et remplir ces sept-là — rien au-dessus de ce module ne change.

Kind

Kind::for_extension est une liste blanche délibérée. txt n’y est pas, pour qu’un fichier de notes égaré à côté des réponses ne devienne jamais un candidat.

Kind est la famille ; l’extension est gardée séparément, parce que ce ne sont pas la même chose :

  • Kind décide comment parser, comment fusionner, et le Content-Type.
  • L’extension décide si un endpoint peut être servi, et quel validateur check appelle. ks et preseed sont tous deux Kind::Text mais ne partagent pas de validateur — ce qui est pourquoi Resolution porte format_name à côté de format.

Filtrer sur la famille plutôt que sur l’extension laisserait un preseed répondre à /rhel/ks.

endpoint_formats

Une petite table d’alias associant un segment d’URL aux extensions qu’il accepte. Deux pièges y vivent, tous deux déjà payés :

  • Filtrer sur l’extension, pas sur le Kind — comme ci-dessus.
  • Un alias doit être assez spécifique pour que personne ne l’atteigne par accident. seed a été retiré : s=http://server/seed/ est une URL de seed NoCloud ordinaire, et elle sert du YAML.

Un segment ne nommant aucun alias ne contraint rien, donc /answer continue de fonctionner.

Règles de fusion

Maps / objetsfusionnent récursivement
Toute autre valeurremplacée intégralement par la couche supérieure
Tableauxremplacent, ils ne concatènent pas
Kind::Textconcaténation dans l’ordre des couches

Les tableaux remplacent parce que concaténer rendrait une liste impossible à raccourcir depuis une couche supérieure, et « ce nœud a deux disques, pas quatre » doit rester exprimable. La règle est la même dans tous les formats, pour qu’on n’ait jamais à se rappeler dans lequel on est.

merge.rs porte le cas TOML, et utilise as_table_like pour que [table] et { inline = "table" } fusionnent entre eux — un groupe peut employer un style et une machine l’autre sans surprise.

Le cas texte est honnête sur le fait d’être une concaténation plutôt que de prétendre le contraire : savoir si cela équivaut à une surcharge est l’affaire du format cible (la dernière réponse gagne en preseed ; pas toujours en kickstart).

L’arbre XML

format/xml.rs est un petit arbre construit à la main par-dessus quick-xml, parce qu’aucune crate généraliste ne préserve ce qu’un document de réponse a besoin de voir préservé.

Appariement. Les enfants sont appariés par nom d’élément plus un attribut discriminant :

const DISCRIMINATORS: [&str; 5] = ["name", "id", "key", "alias", "pass"];

C’est ce qui rend <component name="Microsoft-Windows-Shell-Setup"> et <settings pass="specialize"> fusionnables : surcharger une pass laisse les autres tranquilles.

Des frères répétés ne sont pas toujours une liste. Les traiter comme telle remplaçait chaque <component> d’un unattend.xml par celui que la surcouche mentionnait. S’ils portent un attribut discriminant, ce sont une collection indexée. Le config:type="list" d’AutoYaST est respecté pour le vrai cas des listes.

Fidélité. Déclarations, doctypes, espaces de noms et attributs survivent à une fusion. L’indentation d’origine et le placement des commentaires, non — la sortie est re-rendue, pas rustinée.

quick-xml émet les références d’entité comme leurs propres événements. Les ignorer soude les fragments de texte alentour : 1 &lt; 2 &amp; 3 revenait en 123. Les entités numériques sont résolues ; les inconnues sont refusées plutôt que silencieusement jetées.

Il ne comprend aucun schéma. check appelle xmllint là où il est installé, et c’est l’étendue de la garantie.

Clés de contrôle

pub const CONTROL_KEYS: [&str; 3] = ["extends", "members", "match"];
pub const XML_CONTROL_ELEMENT: &str = "answer-meta";
pub const TEXT_DIRECTIVE: &str = "answer:";

Elles voyagent dans ce que chaque format permet — clés natives de premier niveau dans les formats structurés, un élément <answer-meta> en XML, des directives # answer: (ou // answer:) en texte — et strip_control() les retire toutes avant l’envoi de la réponse.

Control est la forme parsée : extends: Option<String>, members: Vec<String>, matchers: BTreeMap<String, String>.

Templating

Deux règles, toutes deux porteuses :

La substitution se fait sur des valeurs de chaîne parsées, jamais sur le texte brut du document. La valeur entre dans le modèle de données du document et le sérialiseur du format l’écrit, donc c’est le sérialiseur qui échappe. Une valeur contenant un guillemet ne peut pas casser le TOML dans lequel elle atterrit ; une contenant < ne peut pas casser le XML. Un test fait passer a"b'c<d>e&f dans les quatre formats structurés et reparse la sortie.

Un fait manquant est une erreur, jamais une chaîne vide. Servir node-.example.com installe une machine avec un nom d’hôte cassé et personne ne le remarque avant plus tard. Les caractères de contrôle sont refusés pour la même classe de raison — un saut de ligne dans une valeur kickstart injecte une directive dans un fichier que l’installateur exécute.

Group::has_placeholders est pourquoi un groupe sans template ne coûte aucun parsing par requête : la chaîne préparée au chargement est servie telle quelle.

Les exemples travaillés font partie de la conception

examples/ porte un exemple commenté de chacune des treize extensions de la liste blanche, et

RESCRIPTUM_ANSWERS_DIR=examples cargo run -- check

les exerce tous. Gardez-le ainsi. Ils sont le seul endroit où les formats sont montrés en train de se composer ensemble, et deux d’entre eux — suse-node.autoyast et windows-node.unattend — sont ce qui a attrapé le doctype manquant et la pass non appariée.

Les stores

Les stores

Les réponses viennent soit d’un répertoire de documents (RESCRIPTUM_STORE=files, le défaut), soit d’une base SQLite (RESCRIPTUM_STORE=sqlite), au choix à l’exécution.

Le trait est délibérément mince

pub trait Store: Send + Sync {
    fn version(&self) -> Version;               // Option<String>, bon marché par requête
    fn snapshot(&self) -> io::Result<Snapshot>; // seulement quand la version a bougé
    fn describe(&self) -> String;
}

Un Snapshot, c’est du texte brut de documents et rien d’autre : RawMachine, RawGroup, RawDefault, portant chacun un identifiant, un format et un corps.

Chaque décision vit au-dessus de cela. Correspondance, chaînes extends, fusion, rendu, check — tout dans select.rs et merge.rs, partagé. Gardez-le ainsi : dès qu’un backend se met à décider du comportement, les deux divergent et la suite de conformité cesse de pouvoir prouver le contraire.

La moitié écriture est séparée, 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>;
    fn put_group(&self, name: &str, format: &str, body: &str) -> io::Result<()>;
    fn delete_group(&self, name: &str, format: &str) -> io::Result<bool>;
    fn put_default(&self, format: &str, body: &str) -> io::Result<()>;
    fn delete_default(&self, format: &str) -> io::Result<bool>;
}

Chaque opération nomme un format. Un document est indexé par ce à quoi il sert — une machine et un système d’exploitation.

Un put antérieur supprimait les autres formats d’un radical, pour éviter « deux réponses pour une machine ». C’était le mauvais modèle : ce sont les réponses de cette machine pour deux systèmes d’exploitation, et les deux sont censées exister. Voir pièges.

tests/stores.rs est la garantie

Chaque cas de comportement tourne deux fois, une par store, et affirme le résultat identique. 35 cas au dernier compte.

Un nouveau comportement a sa place là, pas dans un test propre à un store. Un test qui couvre un seul backend prouve la moitié de ce qu’il prétend — et la moitié qu’il ne couvre pas est exactement là où se cache une divergence.

Le store fichiers

Un répertoire par identité. Une machine est un répertoire nommé d’après elle, qui contient un document par format ; groups/ porte la même forme pour les groupes, et default/ les réponses de repli. Les deux noms sont réservés, donc une machine ne peut pas les revendiquer — valid_machine_id les refuse dans les deux stores, parce qu’une base qui en accepterait un exporterait vers un répertoire incapable de le contenir.

Dans un répertoire, l’extension est le format et le radical n’est rien du tout. C’est cette règle qui fait de deux documents d’un même format dans un même répertoire un problème signalé plutôt que tranché : il n’existe aucun départage qu’un administrateur aurait pu prévoir. L’ordre trié décide lequel des deux répond, pour que le choix ne dépende au moins pas de readdir — et le perdant est nommé dans problems().

Un document servable laissé à la racine du répertoire de réponses — l’agencement d’avant — est signalé et non servi, avec sa destination explicitée. Lire à moitié un ancien agencement signifierait une machine dont la réponse se déplace silencieusement entre deux fichiers. pending_moves() expose la même connaissance pour migrate, pour que la commande et le lecteur ne puissent pas diverger sur l’endroit où va un document.

version() est le mtime du répertoire :

fs::metadata(&self.dir).ok()
    .and_then(|m| m.modified().ok())
    .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
    .map(|d| d.as_nanos().to_string())

Un stat remplace tout un parcours de répertoire — voir le cache du listing.

Le mtime du répertoire bouge quand une entrée y est ajoutée ou supprimée, pas quand l’une est éditée, ni quand quelque chose change un niveau plus bas. Le répertoire entier d’une machine qui apparaît ou disparaît est donc vu immédiatement, tandis qu’un document ajouté ou modifié à l’intérieur de l’un d’eux attend le filet de rechargement d’une seconde — celui qui couvrait déjà un fichier édité sur place. Un test unitaire épingle chacune des deux moitiés.

Ce que l’agencement coûte à la lecture. Un rechargement complet est désormais un readdir par identité en plus du fichier qu’il ouvrait déjà. Mesuré à 2 000 machines sur un M1 Pro : 28 ms à plat, 63 ms avec un répertoire chacune. C’est amorti sur une seconde de requêtes, et le débit de bout en bout n’a pas bougé de façon mesurable — mais c’est un vrai facteur 2,2 sur la seule opération dont le filet garantit qu’elle tournera chaque seconde, et c’est la raison de préférer un groupe à un répertoire par machine.

Les écritures passent par un fichier temporaire plus un rename, atomique dans un répertoire sur POSIX, pour qu’un lecteur ne rencontre jamais une réponse à moitié écrite. Le nom temporaire porte l’identifiant du processus, et il est supprimé si le rename échoue. Un test affirme qu’aucun fichier .tmp ne survit.

La lecture utilise DirEntry::file_type(), pas fs::metadata. Le type de fichier revient gratuitement avec le readdir sur Unix ; seul un lien symbolique a besoin du stat pour être résolu. Cela seul valait 65 % à 2 000 fichiers, avant que le cache ne soit ajouté.

Le store SQLite

rusqlite avec la feature bundled — SQLite est compilé depuis les sources dans le binaire, donc il n’y a rien à installer. Il se compile en croisé vers armv7-musl sous zigbuild ; la CI construit cette cible à chaque push précisément parce que c’est là qu’une dépendance C casse en premier.

Mode WAL, pour que l’API d’administration ne bloque jamais une installation en cours.

version() lit un atomique en mémoire, pas la base :

Some(self.revision.load(Ordering::Relaxed).to_string())

Elle est appelée à chaque requête, et une requête SQL par requête HTTP annulerait l’intérêt du cache. La conséquence est qu’un changement fait par un autre processus ne la bouge pas — c’est le filet de rechargement qui l’attrape.

Les versions de schéma vivent dans PRAGMA user_version. Il n’y en a qu’une, et rien n’a été publié sous une plus ancienne, donc migrate() n’a aucune étape : il refuse une base venue du futur, crée le schéma quand la version vaut 0, et l’estampille. Les formes par lesquelles ce schéma est passé pendant son écriture ne sont jamais sorties du dépôt, et porter des migrations depuis elles reviendrait à porter du code qui ne peut pas s’exécuter.

Ce à quoi sert la version, c’est le sens du retour arrière :

database schema is version 2, this binary understands 1

Refusé plutôt que deviné, parce qu’une base écrite par un binaire plus récent peut porter des colonnes que celui-ci ignorerait silencieusement — et ignorer silencieusement une partie d’un jeu de réponses, c’est ainsi qu’une machine s’installe de travers.

import / export

$ rescriptum import <dir>    # répertoire → store configuré
$ rescriptum export <dir>    # store configuré → répertoire

Les deux passent par Snapshot, donc ils partagent toutes les règles. L’aller-retour est identique octet pour octet — importez un répertoire, réexportez-le, diff -r ne signale rien, chemins compris. Un test compare les deux côtés au même chemin pour exactement cette raison : export écrivant un document là où import n’irait pas le chercher est ce qui rendrait la base dangereuse à quitter.

Les identifiants deviennent des noms de répertoires

pub fn valid_id(id: &str) -> bool           // lettres, chiffres, - _ . : et aucun séparateur
pub fn valid_machine_id(id: &str) -> bool   // …et ni `groups` ni `default`

Imposé à la frontière de l’API d’administration et dans les deux stores. Le store est la couche qui transforme un identifiant en chemin, donc c’est la couche qui ne doit pas être trompée — ne vérifier qu’à la frontière ferait dépendre le garde-fou du fait que tout appelant futur s’en souvienne.

valid_format est l’équivalent pour les extensions : un document dans un format que personne ne peut lire n’atteint jamais le store.

La feature cargo sqlite

Activée par défaut, et retirable :

BuildTaille ARMv7
par défaut2 103 456 octets
--no-default-features944 928 octets

La retirer retire aussi l’API d’administration, qui a besoin de la base. La CI construit --release --no-default-features à chaque push pour que le plus petit build ne pourrisse pas sans qu’on le remarque.

Internes de l'API d'administration

Internes de l’API d’administration

src/admin.rs, activée seulement par RESCRIPTUM_ADMIN_ADDR, et seulement au-dessus de SQLite. Trois propriétés sont porteuses — un changement qui en supprime discrètement une est une régression.

1. Son propre listener

L’endpoint de réponse n’est pas authentifié par nécessité : l’installateur n’a aucun identifiant à offrir. Cette API décide du mot de passe root et des clés SSH de chaque machine installée ensuite. Elle ne partage jamais ce port.

Config::validate refuse de démarrer — en erreur, pas en avertissement — sans jeton, avec un jeton de moins de 16 caractères, ou au-dessus du store fichiers. Ces vérifications tournent avant que le listener ne soit bindé, donc une mauvaise configuration n’est jamais brièvement en ligne.

2. SQLite uniquement

Au-dessus d’un répertoire de fichiers il y aurait deux façons de changer la même configuration, à la main et par le réseau, en concurrence l’une avec l’autre.

3. L’écriture qui ne peut pas casser le parc

fn guarded(admin, kind, id, format, body) -> Response<Body> {
    let before   = admin.answers.problems()?;   // instantané des dégâts
    let previous = admin.store.snapshot()?…;    // ce qui était là, pour pouvoir le restaurer
    let existed  = apply(…)?;                   // put ou delete
    let after    = admin.answers.problems();
    let introduced = after.filter(|p| !before.contains(p));
    if !introduced.is_empty() { restore(previous); return 409 }
    200 avec `problems: before`
}
  • Seuls les problèmes nouvellement introduits déclenchent l’annulation. Un store déjà cassé reste éditable — sinon un mauvais état serait impossible à réparer par l’API qui l’a causé.
  • Une écriture réussie signale quand même les problèmes préexistants, pour qu’une réponse propre n’implique jamais que tout le jeu est sain.
  • C’est pourquoi un extends de machine pointant sur un groupe manquant est détecté au chargement, dans select.rs, plutôt qu’au moment où cette machine demande. Le garde-fou ne peut attraper que ce que problems() signale. Ajouter une nouvelle classe de casse veut dire l’ajouter là, sinon le garde-fou cesse silencieusement de la couvrir.

Les documents malformés sont refusés à l’écriture (400) plutôt que de devenir un 500 la prochaine fois qu’une machine en demande un.

Authentification

Comparaison en temps constant. Un == ordinaire retourne dès le premier octet différent, ce qui fuit le jeton octet par octet à qui chronomètre les réponses — quelques milliers de requêtes plutôt qu’un nombre impossible. Comparer tous les octets quoi qu’il arrive supprime le signal. Sur un réseau le timing se perd généralement dans la gigue, donc c’est une précaution ; ça coûte cinq lignes.

AuthGuard exclut une adresse après des échecs répétés :

ConstanteValeur
MAX_FAILURES5
FAILURE_WINDOW60 s
BASE_BLOCK60 s, doublant à chaque récidive
MAX_BLOCK900 s
MAX_TRACKED4096 adresses

Trois détails qui ne sont pas des accidents :

  • Le blocage s’applique aussi à un jeton correct. Sinon deviner jusqu’à tomber juste ne coûterait rien.
  • MAX_TRACKED est borné, pour que le garde-fou ne puisse pas lui-même être transformé en fuite de mémoire par un attaquant qui fait tourner ses adresses sources.
  • GET /health est vérifié avant le garde-fou et avant l’auth, pour que la supervision ne s’éteigne pas pendant une attaque.

Un blocage répond 429 avec Retry-After.

Traitement des requêtes

let segments: Vec<&str> = path.trim_matches('/').split('/').collect();
match (&method, segments.as_slice()) {
    (&Method::GET,    ["machines"])      => list(…),
    (&Method::GET,    ["resolve", id])   => resolve(…),
    (&Method::PUT,    ["groups", id])    => put(…).await,

    _ => error(NOT_FOUND, "no such endpoint"),
}

?format= choisit l’extension du document, toml par défaut — ce que ce serveur servait à ses débuts.

Lire le corps de la requête avant de la rejeter. Répondre et fermer pendant que le client écrit encore lui vaut un ECONNRESET au lieu de la réponse. put() draine d’abord, puis valide l’identifiant.

Chaque réponse d’administration doit définir Connection: close. Sans cela, chaque client de test attendait l’expiration de la connexion — la suite prenait 30 s au lieu de 0,4 s — et la coupure finale arrivait parfois comme un reset plutôt qu’un EOF propre.

GET /resolve définit X-Answer-Source avec la même description que la ligne de log.

GET /resolve/{id} ignore l’identifiant du chemin quand une query string est présente — les faits viennent de la query seule, ce qui permet de répéter une vraie requête. Cela rend ?format=toml sur cet endpoint activement faux : il ne résout rien. Documenté dans le guide.

Identifiants

valid_id — lettres, chiffres, - _ . :, aucun séparateur de chemin — est imposé à la frontière de l’API et dans les deux stores. export retransforme les identifiants en noms de fichiers, donc tout ce qui pourrait traverser un répertoire doit être rejeté dans la couche qui construit le chemin, pas seulement dans celle qui l’a reçu.

Connu et accepté

  • La limitation par adresse n’arrête pas un attaquant disposant de nombreuses adresses. C’est la longueur du jeton qui rend la devinette sans espoir — d’où le plancher de 16 caractères au démarrage.
  • Elle parle HTTP en clair. Mettez du TLS devant si elle quitte la boucle locale.
  • Binder au-delà de la boucle locale journalise un avertissement plutôt que de refuser, parce qu’un réseau d’administration est un choix légitime.

Tests

tests/admin.rs (15 cas) couvre le routage, l’annulation, la validation des identifiants et les codes de statut. tests/guards.rs (5) couvre l’arithmétique du verrouillage et le fait que /health reste joignable à travers.

Tests

Tests

619 tests. cargo test les fait tous tourner en une vingtaine de secondes — dont l’essentiel dans tests/tftp.rs, qui attend de vrais délais UDP parce que c’est précisément ce qu’il teste.

cargo test ne lance pas les bancs qui comptent le plus : le banc de démarrage, les trois du paquet DSM, et la construction des chargeurs. Voir Le paquet aussi est testé et le banc de démarrage.

cargo test                                # tout
cargo test <nom>                          # un seul, par sous-chaîne
cargo test -- --nocapture                 # afficher stdout
cargo test --all-features                 # ce que lance la CI

Où un test a sa place

SuiteCasPour
tests/integration.rs52le vrai binaire sur une vraie socket
tests/cli.rs65render, check, import, export, config et le fichier d’environnement — contre le vrai binaire
tests/media.rs45les médias de démarrage contre le vrai binaire, les deux listeners debout
src/config.rs56l’environnement, ce qui refuse de démarrer, et qui l’emporte du fichier ou de l’environnement
tests/stores.rs48chaque comportement, contre les deux stores
tests/tftp.rs30le TFTP sur de l’UDP réel : les tours de parole, et ce qu’une liaison ratée ne doit pas coûter
src/select.rs29normalisation, scoring, superposition, remplissage de templates
src/format/mod.rs28parsing, fusion, clés de contrôle, alias d’endpoint
tests/admin.rs26l’API d’administration de bout en bout, formats compris
src/envfile.rs23le parseur et l’écrivain du fichier d’environnement, et ce que chacun refuse
src/facts.rs22parsing de query, aplatissement JSON, globbing
src/format/xml.rs18l’arbre XML — appariement, entités, fidélité
src/merge.rs11la fusion profonde TOML
tests/guards.rs7le jeton de réponse, et le verrouillage qui délibérément n’existe pas
src/installed.rs6une machine qui signale son installation, et ce qu’il ne faut jamais désarmer
src/log.rs15lecture des niveaux, et l’arithmétique d’horodatage
src/boot/*.rs128le lecteur ISO, le repérage, le catalogue, les sources d’images, les plans de patch, le menu, la table des chargeurs, les extraits DHCP, cpio et SHA-256
src/admin.rs, src/capture.rs, src/store/mod.rs21comportement unitaire

tests/common/mod.rs — les fixtures que toutes les suites partagent

Les réponses sont stockées à raison d’un répertoire par identité, donc une fixture ne peut plus être un simple nom de fichier. seed() prend le nom dans lequel un test pense — 98fa9b50d810.toml, groups/rack-a.toml, default.toml — et l’écrit via StoreWrite, si bien qu’elle atterrit exactement là où une écriture de l’API d’administration la mettrait et ne peut pas diverger de l’agencement. Un nom que le store refuserait (une extension que personne ne sert) est écrit littéralement, parce que ces fixtures existent justement pour prouver qu’un fichier égaré ne répond à rien.

Une seule copie, pas une par suite — le même raisonnement qui fait de loaders.rs une table unique lue par TFTP et par l’extrait DHCP. Quatre copies d’une correspondance sont quatre occasions qu’une fixture atterrisse là où le serveur ne regarde pas, et un test qui ne sème rien passe pour la mauvaise raison.

tests/stores.rs — la suite de conformité

Chaque cas de comportement tourne deux fois, une par store, et affirme le résultat identique. Cette suite est ce qui empêche deux backends de diverger.

Un nouveau comportement a sa place là, pas dans un test propre à un store. Un test qui couvre un seul backend prouve la moitié de ce qu’il prétend — et la moitié qu’il ne couvre pas est exactement là où se cache une divergence.

tests/cli.rs — les commandes qu’on dit aux gens de lancer

check est ce que deploy.sh lance avant d’expédier quoi que ce soit, et ce que la documentation dit de mettre en CI — donc son code de sortie est un contrat, pas une commodité. La séparation stdout/stderr de render en est un autre : le document part sur stdout pour que render … > answer.toml donne un fichier utilisable, et la ligne de provenance part sur stderr pour qu’elle ne s’y retrouve pas.

Également épinglé ici : l’aller-retour importexport est identique octet pour octet, commentaires et mise en forme compris. C’est ce qui rend la base sûre à adopter et sûre à quitter ; si cela cesse d’être exact, export n’est plus une porte de sortie.

tests/integration.rs — contre le vrai binaire

Il démarre le binaire réel sur un port éphémère et lui parle en HTTP. Le binaire affiche l’adresse qu’il a bindée, donc il n’y a ni course sur le port ni « on dort et on espère ».

Cette suite existe parce que certains échecs sont invisibles aux tests unitaires. L’exemple le plus clair : hyper panique à l’exécution si header_read_timeout est défini sans .timer(…). Ça compile. Seule une vraie connexion le trouve.

Explicitement couverts :

  • une requête tronquée, et une sans Content-Length ;
  • un Content-Length aberrant — et un corps chunked qui dépasse le plafond en cours de route, l’autre chemin d’entrée, où la limite saute en pleine lecture ;
  • une méthode inconnue, un corps vide, un corps de 1 Mo, un corps qui n’est pas de l’UTF-8 valide ;
  • le plafond de connexions : au-delà, un 503 immédiat plutôt qu’une file — et le permis qui revient ensuite ;
  • et, après chacun de ces cas, que le serveur répond encore. C’est cette dernière assertion qui compte — la maltraitance n’a d’intérêt que si le serveur y survit.

cargo test ne reconstruit pas target/debug/rescriptum. Une vérification manuelle contre un binaire périmé a un jour « reproduit » un bug déjà corrigé. Reconstruisez avant de triturer le binaire à la main.

tests/tftp.rs — un transfert est une conversation

Rien ici ne se prouve depuis l’intérieur d’une fonction. Les blocs, les acquittements, la retransmission, le paquet vide qui termine un transfert — chaque bug qui vaut d’être attrapé vit dans les tours de parole, et la première exécution en a trouvé deux, du genre « marche à la main, jamais après un redémarrage ». Un fichier dont la longueur est un multiple exact de la taille de bloc doit se terminer par un paquet de données vide ; sans lui le client attend éternellement un dernier bloc qui ne vient jamais.

Cette suite porte aussi le seul écouteur de ce serveur dont l’échec n’est pas fatal. Un port TFTP qu’on ne peut pas lier ne doit pas emporter les réponses et les médias avec lui — mesuré sur DSM, où la capacité est accordée hors du paquet et où une mise à jour la perd — donc le test squatte le port, puis vérifie trois choses d’un coup : le serveur est monté, il a averti en disant ce qui marche encore, et boot check sort toujours en non-zéro.

Cette dernière assertion est d’abord passée pour la mauvaise raison : trois chargeurs manquants faisaient déjà échouer la commande. Le montage écrit maintenant tous les chargeurs que la table nomme, et une exécution témoin avec le TFTP coupé prouve que le répertoire est propre par ailleurs.

tests/media.rs — les médias de démarrage contre le vrai binaire

Les deux listeners debout, et chaque cas d’abus se termine en prouvant que le serveur répond toujours. Un cas prouve la propriété pour laquelle la socket séparée existe : les réponses continuent d’aboutir pendant que quatre transferts d’image sont en cours.

Il n’y a délibérément aucune ISO binaire dans ce dépôt. boot::iso::build écrit des images en mémoire, derrière la fonctionnalité test-support, pour qu’elle n’atteigne jamais un binaire de release.

La chaîne de démarrage a sa place dans le banc

packaging/boot-rig/run.sh n’est pas du Rust et cargo test ne le lance pas. Il démarre une machine revendiquée et une non revendiquée dans QEMU sous TCG, sur un pont privé sans lien montant, et vérifie quatre marqueurs : la passe DHCP a répondu depuis notre propre extrait généré, un chargeur a été récupéré en TFTP, la machine non revendiquée est retombée sur son disque local, et la machine revendiquée a atteint sa propre réponse. La CI fait la même chose plus une casse délibérée.

Un invité QEMU ponté dans un conteneur a une MAC à lui, et le commutateur virtuel de Docker Desktop ne transmet pas les trames d’une MAC qu’il n’a pas attribuée — mesuré, d’où un banc principal en un seul conteneur plutôt qu’en quatre sur un réseau Docker.

Vérifier qu’un test peut échouer

Un test qui passe pour la mauvaise raison est pire que pas de test : il annonce une couverture qui n’existe pas. Avant de faire confiance à un nouveau test, cassez ce qu’il garde et regardez-le rougir.

Un test de cette suite n’a pas survécu à cette vérification. Il prétendait protéger la clause version.is_some() du cache du listing ; en la retirant, il restait vert — parce qu’avec l’un comme l’autre store, une version n’est illisible que lorsque le store est aussi vide, si bien que la clause ne peut pas se déclencher. Le test prouve quelque chose de réel — un répertoire qui apparaît après le démarrage est servi dès la requête suivante — et le dit maintenant.

Assertions à copier

  • Affirmez sur des valeurs parsées, pas sur la mise en forme. Remplacer une table par un scalaire laisse la décoration d’origine de la clé, donc la sortie peut se lire value= 3 — du TOML valide, un texte différent. Une comparaison de chaînes échoue là pour la mauvaise raison, ou passe pour une mauvaise raison.
  • Les tests d’invalidation de cache doivent partager une seule instance d’Answers. Un test qui en construit une nouvelle à chaque appel contourne complètement le cache et ne prouve silencieusement rien.
  • Config::from_lookup prend une closure, pour que les tests de configuration ne touchent jamais l’environnement du processus — et ne se courent donc jamais après sous un runner parallèle.
  • Vérifiez que l’ancien texte a bien été trouvé avant d’écrire. Deux patchs python/sed dans l’histoire de ce projet n’ont silencieusement rien matché et n’ont été attrapés qu’en vérifiant le nombre de tests ensuite.

Les exemples de réponses sont aussi un test

RESCRIPTUM_ANSWERS_DIR=examples cargo run -- check

examples/ contient un exemple travaillé de chaque format, et c’est le seul endroit où ils sont montrés en train de se composer ensemble. Deux d’entre eux ont attrapé de vrais bugs — un doctype manquant et un attribut pass non apparié. Gardez-les fonctionnels.

Le paquet aussi est testé, à trois endroits

cargo test ne touche pas au paquet DSM, parce que rien là-dedans n’est du Rust. Trois harnais s’en chargent, et chacun prouve ce que les autres ne peuvent pas.

ProuveCoût
packaging/dsm/check-spk.shl’archive est structurellement ce que DSM attend — tar externe non compressé, les six champs d’INFO, une version tout en segments numériques, os_min_ver au moins 7.1, icônes 64×64 et 256×256, scripts exécutables sans CRLF, le --version du binaire empaqueté, et l’application de bureau : un dsmappname nommant une classe que son ui/config déclare vraiment, un nom de fichier JavaScript qui porte la version, et un backend qui vérifie toujours la session DSM et administratorsdes secondes, à chaque push
packaging/dsm/lifecycle-test.shtout ce que les scripts du paquet décident, contre un faux arbre /var/packages : le fichier d’environnement écrit une fois et une seule, les valeurs de l’assistant et leur absence, le service qui survit à son propre script de démarrage et répond à /health, les codes de sortie que lit Package Center, une mise à jour qui ne doit pas toucher une configuration éditée à la main, une désinstallation qui ne doit pas toucher aux réponses — et le backend de l’application de bureau, piloté avec un authentificateur bouchonné : refuser l’absence de session, refuser un non-administrateur, refuser une écriture sans en-tête d’intention, refuser celle qui empêcherait le serveur de démarrer, et ne jamais livrer un jeton au navigateurdes secondes, à chaque push
packaging/dsm/vm/on-dsm.shla machinerie propre à DSM — le worker data-share et son ACL, le worker port-config, l’unité systemd générée, logrotate contre un descripteur vivant, si Package Center accepte l’archive — et qu’une machine qui demande sa configuration en reçoit une : un POST avec le matériel dans le corps, auquel répond le fichier de cette machine fusionné par-dessus le groupe qui la revendique. Elle porte aussi la seule route vers le port 69 et la capacité de ce NAS à atteindre l’index d’un éditeur : que 69/udp survive dans l’entrée de pare-feu acquise, que le paquet réponde encore sans la capacité, et que setcap cap_net_bind_service=+ep puis un redémarrage lient udp/69 sous le processus non privilégié du paquetdes minutes, sur une VM DSM 7 — puis sur le DS416j
packaging/dsm/lifecycle-test.sh                     # le premier .spk de dist/ qui tourne ici
docker compose -f packaging/dsm/vm/docker-compose.yml up -d   # une machine DSM 7.2
packaging/dsm/vm/on-dsm.sh admin@<hôte> -p 2222     # contre elle
packaging/dsm/vm/on-dsm.sh admin@nas                # le verdict

La VM, c’est vdsm/virtual-dsm, qui installe la Virtual DSM officielle de Synology — aucune image de loader à trouver. KVM la rend rapide, pas possible : sans /dev/kvm elle émule, dix fois plus lentement, et c’est à ça que sert docker-compose.emulated.yml. En revanche elle veut 14 Gio libres pour son stockage, en dur dans l’image.

Le dernier est destructeur exprès — il met à jour par-dessus un fichier d’environnement édité à la main et un canary dans le dossier partagé, puis désinstalle, puis vérifie que les deux ont survécu. Ces deux gardes sont les choses les plus coûteuses à rater dans ce paquet, et le premier .spk publié est celui dont les scripts de désinstallation tourneront pendant la première mise à jour de tout le monde. packaging/dsm/vm/README.md décrit le banc d’essai : ce dont il est une preuve, et ce dont il ne l’est pas.

La même règle que partout ailleurs vaut pour eux : cassez ce qu’ils gardent et regardez-les virer au rouge. Annuler la garde de postinst à la mise à jour, faire supprimer le partage par postuninst, renvoyer 1 pour un paquet arrêté et refuser prestart transforme 33 vérifications vertes en 25 vertes et 8 rouges — c’est ainsi qu’on sait que le harnais teste quelque chose. Aujourd’hui c’est 85 vérifications dans lifecycle-test.sh, 28 dans check-spk.sh et 52 sur la machine ; les trois dernières ajoutées ont chacune été vues rouges de la même façon — en remettant RESCRIPTUM_TFTP_ADDR=off, en supprimant le rapport du panneau sur l’état du TFTP, et en lui faisant prétendre qu’il livre alors que rien n’est lié.

CI

.github/workflows/ci.yml, à chaque push sur main et develop et à chaque pull request :

JobLance
gatescargo fmt --all --check, cargo clippy --all-targets --all-features -D warnings, cargo test --all-features, cargo build --release --no-default-features
docsconstruit le site public et lance notabene lint
auditcargo audit --deny warnings sur l’arbre de dépendances
crossun build ARMv7-musl complet, puis affirme que le binaire est bien statically linked, puis assemble les deux .spk, les contrôle structurellement et déroule le cycle de vie du paquet

Le job cross n’est pas redondant. SQLite est compilé depuis les sources, et armv7-musl est la cible la moins indulgente qui soit livrée — c’est là qu’une dépendance C casse en premier. L’attraper sur un push vaut mieux que l’attraper en train de faire une release.

Le job audit est l’autre moitié de la règle « ajouter une dépendance exige une raison » : une raison de l’ajouter n’est pas une raison de la garder. --deny warnings échoue aussi sur un crate non maintenu ou yanké, pas seulement sur une vulnérabilité. Quand quelque chose apparaît sans correctif, ajoutez --ignore RUSTSEC-… avec une ligne expliquant pourquoi, plutôt que de retirer le drapeau.

Chaque action utilisée est une action officielle actions/*, et Zig comme cargo-audit sont installés directement plutôt que via une action tierce. C’est délibéré : cette toolchain vérifie et lie un binaire que des gens font tourner en root.

Le site de documentation a son propre garde-fou — voir le site de documentation.

Construire

Construire

./build.sh                    # cette machine, et affiche la taille
./build.sh --all              # toutes les cibles qu'une release livre
./build.sh --no-sqlite        # le plus petit binaire
./build.sh armv7-unknown-linux-gnueabihf
./build.sh --help

build.sh ajoute une cible Rust manquante pour vous et avertit si un build musl est sorti lié dynamiquement — ce que DSM refuserait de lancer, au moment de l’exec sur le NAS plutôt qu’au build sur votre portable.

cargo build tout court fonctionne aussi ; build.sh existe pour le rapport de taille et cet avertissement.

Les cibles de release

CiblePourCroisé
armv7-unknown-linux-gnueabihfle DS416j, la raison d’être du projet — glibc, pas musl, voir plus baszigbuild, plancher 2.17
aarch64-unknown-linux-muslNAS ARM récents, Raspberry Pizigbuild
x86_64-unknown-linux-muslla plupart des autres hôtes Linuxzigbuild
aarch64-apple-darwindéveloppement localnatif
x86_64-apple-darwindéveloppement localnatif

Compilation croisée

cargo-zigbuild utilise Zig comme éditeur de liens, ce qui évite une toolchain croisée complète par cible :

cargo install cargo-zigbuild
cargo zigbuild --release --target armv7-unknown-linux-gnueabihf.2.17

Pourquoi armv7 est la seule cible qui ne soit pas musl

Toutes les autres cibles sont en musl statique. ARMv7 est en glibc, et ce n’est pas une préférence : c’est la seule façon dont la machine qui justifie ce projet exécute le binaire.

Les noyaux ARMv7 de Synology sont des 3.10, et ils répondent EINVAL aux appels système time64 là où on attendrait ENOSYS. musl 1.2 a fait passer time_t à 64 bits sur les architectures 32 bits et tente d’abord clock_gettime64 (ainsi que clock_nanosleep et le futex temporisé), avec un repli sur l’appel 32 bits conditionné à ENOSYS. Sur un noyau qui dit EINVAL, le repli n’arrive jamais et toute demande d’heure échoue. Mesuré sur un DS416j en DSM 7.1, noyau 3.10.108 :

$ ./probe
libc clock_gettime(CLOCK_REALTIME)  -> -1  errno=22 (Invalid argument)
syscall 263 (time32)                -> 0   ok
syscall 403 (time64)                -> -1  errno=22 (Invalid argument)

Le symptôme : un binaire qui répond à --version puis panique dès qu’il veut un horodatage — time.rs:131, Os { code: 22, kind: InvalidInput }. Ce n’est ni un problème d’ABI ni un noyau trop vieux pour les instructions, ce à quoi ça ressemble pourtant.

La glibc, en 32 bits, utilise les appels time32, et DSM fournit la sienne (2.20 sur armada38x). Le build armv7 vise donc un plancher glibc 2.17 — assez bas pour DSM, et comme la glibc est rétrocompatible, le même binaire tourne aussi sur un Linux ARMv7 récent.

Ce qu’il faut vérifier n’est donc plus qu’il est statique, mais qu’il ne réclame aucune glibc plus récente que le plancher. Plus récent échoue au moment de l’exec, sur le NAS, en nommant une version de symbole et rien d’autre :

$ readelf --dyn-syms target/armv7-unknown-linux-gnueabihf/release/rescriptum \
    | grep -o 'GLIBC_[0-9.]*' | sort -uV | tail -1
GLIBC_2.17

La CI l’affirme à chaque push. Les cibles musl, elles, restent vérifiées comme statiques, parce que pour elles c’est la promesse.

Installer Zig sur la machine du mainteneur

Zig n’est pas une installation Homebrew ici : brew install avorte sur cette machine à cause de taps tiers non fiables sans rapport avec Zig. Il vit dans ~/.local/zig, avec un lien symbolique dans ~/.local/bin/zig. Pour le mettre à jour, remplacez ce répertoirebrew upgrade zig ne fait rien.

Toolchain vérifiée : Rust 1.93, cargo-zigbuild 0.23.0, Zig 0.16.0, avec les cibles aarch64-apple-darwin et armv7-unknown-linux-gnueabihf installées.

Le profil release

[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true

panic = "abort" est délibérément absent — voir contraintes. Coût mesuré du maintien du déroulement sur ARMv7 : +2416 octets, +0,8 %.

Taille

BuildARMv7
par défaut2 103 456 octets
--no-default-features (ni SQLite, ni API d’administration)944 928 octets

L’essentiel de la différence est SQLite embarqué, compilé depuis les sources. La CI construit --release --no-default-features à chaque push pour que le petit build ne pourrisse pas sans qu’on le remarque.

Features

FeatureDéfautApporte
sqliteactivéele store SQLite et l’API d’administration
cargo build --no-default-features          # le plus petit
cargo test --all-features                  # ce que lance la CI

Le paquet Synology

Un .spk est un format de release, pas un build : le binaire est fini avant que l’empaquetage commence, il n’y a pas de build spécifique à DSM, et rien dans src/ ne sait que Synology existe.

./build.sh --spk x86_64-unknown-linux-musl   # compiler, puis emballer
packaging/dsm/make-spk.sh armv7              # emballer un build qui existe déjà
packaging/dsm/check-spk.sh                   # contrôle structurel sur dist/*.spk

Le paquet embarque les chargeurs : construisez-les d’abord, sinon il ne passe pas son propre contrôle. make-spk.sh les prend dans packaging/ipxe/out (remplaçable par RESCRIPTUM_LOADERS), et check-spk.sh refuse un paquet qui n’en a pas — un serveur TFTP sans rien à distribuer ne démarre personne. Construire iPXE demande une chaîne C Linux, ce qui sur un Mac veut dire un conteneur :

docker run --rm --platform linux/amd64 -v "$PWD:/w" -w /w debian:bookworm-slim sh -c '
  apt-get update -qq &&
  apt-get install -y --no-install-recommends build-essential liblzma-dev mtools \
    xorriso isolinux gcc-aarch64-linux-gnu git ca-certificates perl &&
  packaging/ipxe/build.sh --out /w/packaging/ipxe/out'

Une fois, pas par paquet : les chargeurs sont les mêmes octets dans le .spk de chaque ABI, puisqu’ils tournent sur les machines démarrées, pas sur le NAS. packaging/ipxe/out est gitignoré — jamais de binaires dans git.

ABIarch dans INFODepuis
x86_64x86_64 — le nom de famille, donc toutes les plateformes Intelx86_64-unknown-linux-musl
armv7armada38x — le raccourci de famille n’atteint pas les plateformes Marvellarmv7-unknown-linux-gnueabihf
aarch64armv8aarch64-unknown-linux-musl, une fois le binaire lancé sur l’une d’elles

La règle pour élargir : revendiquer un ABI une fois le binaire lancé sur son membre au noyau le plus ancien, jamais parce qu’une plateforme est plausible.

make-spk.sh est déterministe — mtimes fixes, propriété 0:0, ustar, gzip -n, liste de fichiers pré-triée — donc les mêmes entrées donnent un .spk identique octet pour octet, ce qui est ce qui donne du sens à la somme publiée.

check-spk.sh tourne dans la CI à chaque push. Il vérifie que l’archive externe est un tar non compressé, que INFO a ses six champs obligatoires et une version tout en segments numériques, que les icônes font exactement 64×64 et 256×256, que les scripts de cycle de vie s’analysent et sont exécutables, et que le --version du binaire empaqueté correspond à INFO — le build x86_64 tourne sur le runner, donc cette dernière assertion est réelle et non une relecture de la même chaîne.

lifecycle-test.sh déroule ensuite les scripts du paquet contre un faux arbre /var/packages — installation, démarrage, /health, les codes de sortie, une mise à jour par-dessus une configuration éditée à la main, une désinstallation par-dessus un canary dans le partage — et tourne lui aussi à chaque push.

packaging/dsm/lifecycle-test.sh

Ce que rien de tout cela ne peut prouver, c’est que DSM acceptera le paquet ; seule une installation le peut. C’est le banc d’essai de packaging/dsm/vm/ : un lanceur QEMU, et un script qui joue les vérifications sur la machine — la VM pendant qu’on itère, le DS416j pour le verdict. Voir tests.

Déployer un build

./deploy.sh admin@nas
./deploy.sh admin@nas /volume1/netboot

Construit, vérifie les réponses et refuse d’expédier si elles ne reviennent pas propres, copie sous un nom temporaire, redémarre, et confirme /health. Voir déploiement.

EnvironnementDéfaut
TARGETarmv7-unknown-linux-gnueabihf
ANSWERS<répertoire-distant>/answers
PORT8000

Branches et releases

Branches et releases

Le modèle reprend délibérément celui du projet frère notabene — même mainteneur, mêmes attentes.

Branches

BrancheRègle
mainstable. Seuls les commits de release et les tags vX.Y.Z y atterrissent. Ne poussez jamais de travail de fonctionnalité directement
developintégration. Maintenue à la prochaine version en cours
feature/<nom>, fix/<nom>partent de develop, PR de retour vers develop
main ──●────────────────────────●─(tag vX.Y.Z)──▶  releases
        \                      /
develop  ●───●───●───●───●────●  ────────────────▶  garde-fous CI seulement, ne publie rien
          \     /   \       /
    feature/…  ●   fix/… ●          (PR vers develop)

develop ne publie rien. Elle lance les garde-fous — build, tests, clippy, fmt — et s’arrête là. Pas de préversions, pas d’artefacts. Les binaires sont produits uniquement par un tag vX.Y.Z sur main.

C’est la seule chose qui ne se reporte pas depuis notabene, qui est un paquet npm et publie des préversions sur un dist-tag @dev. Ce projet livre un binaire compilé, donc l’artefact de release est une GitHub Release avec des binaires compilés en croisé attachés, construits par une matrice CI.

Commits

Commits conventionnels avec un scope :

feat(http): answer GET as well as POST
fix(select): normalize member strings before comparing
chore: release v0.2.0

Gardez les PR ciblées. Ajouter une dépendance exige une raison dans le message de commit — ce binaire tourne en root sur le matériel d’autres gens.

Faire une release

# sur develop, tout étant vert
$EDITOR Cargo.toml          # monter la version
cargo build                 # rafraîchir Cargo.lock
git commit -am "chore: release vX.Y.Z"

git checkout main && git merge --no-ff develop
git tag -a vX.Y.Z -m "rescriptum vX.Y.Z"
git push origin main --follow-tags

.github/workflows/release.yml ensuite :

  1. Refuse le tag s’il diverge de Cargo.toml. Une release dont le binaire annonce une version différente de son tag est un problème de support qui survit à la release.
  2. Compile en croisé les cinq cibles publiées.
  3. Empaquette chacune en rescriptum-<version>-<cible>.tar.gz, avec README.md et LICENSE à côté du binaire, plus une somme SHA-256 — qui fait tourner cela en root devrait pouvoir vérifier ce qu’il a téléchargé.
  4. Construit les chargeurs iPXE marqués depuis le commit épinglé et les attache en rescriptum-boot-assets-<version>.tar.gz, après avoir demandé à boot check si le répertoire satisfait la table de chargeurs depuis laquelle le serveur distribue. Sans cela la release est incomplète, et silencieusement : un déploiement obtient un serveur TFTP sans rien à distribuer, et chaque machine que l’extrait DHCP généré envoie là demande un fichier, n’obtient rien, et s’arrête. C’est un téléchargement à part, jamais dans une archive binaire ni dans un .spk — c’est iPXE, en GPLv2, et des fichiers séparés servis à côté relèvent de la simple agrégation, avec packaging/ipxe/ pour offre écrite.
  5. Emballe les builds musl Linux en paquets Synology, rescriptum-<version>-<build>-<abi>.spk, et contrôle structurellement chacun avant qu’il puisse être publié.
  6. Crée la GitHub Release avec gh et --generate-notes, ou verse dedans si elle existe déjà.

Il est relançable à la main via workflow_dispatch avec un tag, pour quand un job échoue après que le tag est déjà poussé.

Un correctif d’empaquetage seul n’a pas besoin de tag. Les versions SPK sont faites de segments tous numériques et le dernier est un numéro de build de paquet, donc v0.1.0 donne 0.1.0-1 ; un déclenchement manuel avec spk_build: 2 attache rescriptum-0.1.0-2-<abi>.spk à la même Release. Une préversion ne produit aucun .spk — les archives sont le canal des préversions.

Un tag ne doit pas être la première fois qu’un .spk est installé sur une machine DSM. Le contrôle structurel attrape une archive cassée ; seul Package Center attrape un paquet cassé, et le premier publié est celui dont les scripts de désinstallation tourneront pendant la première mise à jour de tout le monde. La liste des vérifications est dans packaging/dsm/README.md.

Chaque action utilisée est une action officielle actions/*, et gh est déjà sur le runner. C’est délibéré, pour la même raison que tout le reste de cette page.

Versionnage

SemVer. Le tag est vX.Y.Z et doit correspondre exactement à Cargo.toml.

Les documents de réponse sont des données, pas de l’état : rien ne migre, et un nouveau binaire lit le même répertoire. L’exception est le schéma SQLite, qui porte un user_version — voir stores. Il n’y a qu’une version pour l’instant. En ajouter une seconde veut dire écrire l’étape de migration et une montée mineure au minimum, et les notes de version doivent le dire, parce qu’un binaire plus ancien refusera la base mise à jour plutôt que de la lire à moitié.

Documentation

Le site de documentation est publié depuis main, donc un changement de doc part avec la prochaine release — ou en lançant le workflow docs à la main (workflow_dispatch) quand il ne doit pas attendre.

Pièges déjà rencontrés

Pièges déjà rencontrés

Chacun de ceux-ci a coûté du temps réel. Aucun n’est évident à la seule lecture du code.

À l’exécution, pas à la compilation

hyper panique si un timeout est défini sans timer. http1::Builder::header_read_timeout exige .timer(TokioTimer::new()). Omettez-le et chaque connexion panique à l’exécution — cela ne casse pas la compilation. Les tests d’intégration l’ont attrapé ; des tests unitaires n’auraient pas pu.

header_read_timeout s’arrête à la fin des en-têtes. hyper n’a pas de délai de lecture de corps, donc un client qui promet un corps sans l’envoyer garerait une connexion indéfiniment. Le tokio::time::timeout sur la connexion entière dans connection() est ce qui couvre cela. Les deux sont nécessaires ; aucun n’est redondant.

hyper émet les noms d’en-tête en minuscules. C’est correct — ils sont insensibles à la casse — donc affirmez sur une copie en minuscules. Voir has_header dans les tests d’intégration.

Performance

fs::metadata par entrée de répertoire est un appel système stat chacun. DirEntry::file_type() revient gratuitement avec le readdir sur Unix ; seul un lien symbolique a besoin du stat pour être résolu. Cela seul valait 65 % à 2 000 fichiers, avant que le cache ne soit ajouté.

Éditer le contenu d’un fichier de groupe ne change aucun mtime de répertoire. Seul RELOAD_BACKSTOP (1 s) l’attrape, ce qui est pourquoi le filet n’est pas redondant avec la vérification du mtime. Un test d’intégration le couvre.

Un Content-Length aberrant doit être refusé depuis l’en-tête, pas en laissant Limited sauter après avoir tamponné un mégaoctet.

Fermer sur un pair qui écrit encore jette la réponse qu’on vient d’écrire. Le noyau envoie un reset, et le reset détruit les octets non lus — le client voit donc une connexion coupée, pas votre réponse. shed() avait exactement ce défaut : il écrivait son 503 et fermait aussitôt, si bien que l’installateur à qui il essayait de dire « réessaie » recevait un reset. Il draine maintenant brièvement d’abord, comme le faisait déjà le put() de l’API d’administration. Un test au plafond de connexions l’épingle.

  • macOS autorise un processus non privilégié à lier le port UDP 69 ; Linux non. Un test qui atteint l’adresse TFTP par défaut prend donc une branche différente sur chaque plateforme — boot check traite un port libre mais silencieux comme une note, et un port non liable comme un problème, ce qui est la bonne règle et exactement ce qui rend le test dépendant de la plateforme. Il passait en local et échouait en CI pour une raison sans rapport avec le changement. Tout test qui définit RESCRIPTUM_BOOT_DIR doit aussi définir RESCRIPTUM_TFTP_ADDR=off, sauf si la sonde est le sujet ; tests/tftp.rs couvre le port non liable sur un port haut.
  • Une branche développée entièrement hors ligne n’a jamais rencontré la CI. Celle-ci a accumulé 57 commits avant son premier push, et le premier run a échoué sur deux choses qu’aucune exécution locale ne pouvait voir : un clippy cinq versions plus récent que la toolchain locale épinglée, et une permission de port propre à Linux. Poussez assez tôt pour le découvrir, ou attendez-vous à le découvrir tard.

Sélection et formats

Un Mac qui édite le répertoire de réponses en SMB peut détourner la réponse d’une machine. macOS écrit un fichier AppleDouble ._<nom> à côté d’un fichier dont le système n’accepte pas les attributs étendus — ._proxmox.toml a une extension présente dans la liste. Avec un répertoire par identité, c’est pire que du temps des réponses à plat : c’est un second .toml dans un répertoire qui n’en accepte qu’un, et il se trie avant le vrai, si bien qu’une règle prenant le premier servirait un contenu binaire à toutes les requêtes. La machine qu’on configurait reçoit alors une erreur d’analyse au lieu de sa réponse. .DS_Store n’est inoffensif que par chance (son extension n’est pas dans la liste). Le store fichiers ignore toute entrée dont le nom commence par . ; trouvé sur un vrai NAS, pas en lisant quoi que ce soit.

Normaliser un motif de sélecteur retire * et ? à moins d’utiliser normalize_pattern — ce qui transforme chaque glob en littéral, en silence.

Dans un format texte, un placeholder à l’intérieur d’un commentaire reste un placeholder. Kind::Text est une chaîne opaque, donc la substitution parcourt tout le document — un {{ mac }} écrit dans un commentaire # pour expliquer le templating doit quand même se résoudre, et fait échouer check exactement comme un vrai. Trouvé en ajoutant les exemples travaillés .ipxe et .cfg.

Un GET n’a pas de corps, donc la botte de foin est vide. Les valeurs de query et les segments de chemin doivent l’alimenter aussi, sinon un document nommé d’après une MAC ne peut jamais répondre à une récupération de preseed ou de kickstart.

quick-xml émet les références d’entité comme leurs propres événements. Les ignorer soude les fragments de texte alentour : 1 &lt; 2 &amp; 3 revenait en 123.

Des frères XML répétés ne sont pas toujours une liste. S’ils portent un attribut discriminant, ce sont une collection indexée ; les traiter comme une liste remplaçait chaque <component> d’un unattend.xml par celui que la surcouche mentionnait.

Deux documents ayant le même radical ne sont pas des doublons. Un put antérieur supprimait les autres formats d’un radical pour éviter « deux réponses pour une machine ». C’était le mauvais modèle : ce sont les réponses de cette machine pour deux systèmes d’exploitation.

Filtrer les endpoints sur l’extension, pas sur le Kind. .ks et .preseed sont tous deux Kind::Text ; filtrer par famille laisserait un preseed répondre à /rhel/ks.

Un alias doit être assez spécifique pour que personne ne l’atteigne par accident. seed a été retiré comme alias d’endpoint : s=http://server/seed/ est une URL de seed NoCloud ordinaire, et elle sert du YAML.

L’API d’administration

Lire le corps de la requête avant de la rejeter. Répondre et fermer pendant que le client écrit encore lui vaut un ECONNRESET au lieu de la réponse. put() draine d’abord, puis valide l’identifiant.

Les réponses d’administration doivent définir Connection: close. Sans cela, chaque client de test attendait l’expiration de la connexion — la suite prenait 30 s au lieu de 0,4 s — et la coupure finale arrivait parfois comme un reset plutôt qu’un EOF propre.

Les identifiants deviennent des noms de fichiers. export et le store fichiers construisent des chemins à partir d’identifiants de machine et de noms de groupe, donc valid_id est imposé à la frontière de l’API et dans les deux stores.

Tests

cargo test ne reconstruit pas target/debug/rescriptum. Une vérification manuelle contre un binaire périmé a un jour « reproduit » un bug déjà corrigé. Reconstruisez avant de triturer le binaire à la main.

Les tests d’invalidation de cache doivent partager une seule instance d’Answers. Un test qui en construit une nouvelle à chaque appel contourne complètement le cache et ne prouve silencieusement rien.

Affirmez sur des valeurs parsées, pas sur la mise en forme. Remplacer une table par un scalaire laisse la décoration d’origine de la clé, donc la sortie peut se lire value= 3 — du TOML valide, un texte différent.

Un patch python/sed qui « réussit » peut n’avoir rien matché. Deux éditions dans l’histoire de ce projet n’ont silencieusement rien fait et n’ont été attrapées qu’en vérifiant le nombre de tests ensuite. Vérifiez que l’ancien texte a été trouvé avant d’écrire.

Empaqueter pour DSM

Un script shell qui marche sur macOS n’est pas un script qui marche en CI. Deux cas trouvés en faisant tourner les harnais dans un conteneur Linux plutôt qu’en leur faisant confiance : stat -f '%Lp' est le drapeau de format sur BSD et statut du système de fichiers sur GNU — où il réussit, en déversant des informations d’overlayfs dans une variable censée contenir un mode de fichier, si bien que le repli ne se déclenche jamais. Demander d’abord à GNU (stat -c '%a' || stat -f '%Lp'), qui échoue proprement sur macOS. Et shasum est un script Perl qu’une Debian minimale n’a pas : sha256sum vient de coreutils et existe partout sur Linux. Les runners Ubuntu ont les deux, ce qui est exactement la façon dont un script pareil part cassé chez tous les autres.

musl 1.2 ne peut pas tourner sur les noyaux ARMv7 de Synology, et le symptôme ne nomme rien. Ces noyaux sont des 3.10 et répondent EINVAL aux appels time64 ; musl ne se replie sur les appels 32 bits que sur ENOSYS, donc clock_gettime, clock_nanosleep et le futex temporisé échouent tous. Le binaire s’installe, répond à --version, puis panique à time.rs:131 avec Os { code: 22, kind: InvalidInput } dès qu’il veut un horodatage — ce qui ressemble à un problème d’ABI ou de noyau trop vieux, et n’est ni l’un ni l’autre. La cible armv7 est en glibc avec un plancher 2.17 pour cette raison ; les cibles 64 bits n’ont pas le clivage time32/time64 et ne sont pas concernées. Prouvé par une sonde C de dix lignes sur la machine, pas en lisant quoi que ce soit.

SYNOPKG_PKGDEST vaut /volume1/@appstore/<paquet>, pas /var/packages/<paquet>/target. Le second est un lien vers le premier, donc dirname "$SYNOPKG_PKGDEST" donne /volume1/@appstore et tout ce qu’on y accroche — etc/, var/, shares/ — atterrit là où rien ne lit. La racine du paquet est un chemin fixe. Ça coûte un service qui s’installe parfaitement et ne démarre jamais, et un harnais sur faux arbre ne peut pas l’attraper : dans un arbre qu’on a construit soi-même, dirname tombe juste par construction.

$SYNOPKG_TEMP_UPGRADE_FOLDER survit à la mise à jour qui l’a créé. Une installation neuve qui le lit y trouve la configuration d’une installation que l’utilisateur a supprimée, et la restaure en silence — jetons compris. La restauration doit exiger SYNOPKG_PKG_STATUS = UPGRADE.

etc/ et var/ survivent à une désinstallation. Ce sont des liens vers /volume1/@appconf/<pkg> et /volume1/@appdata/<pkg>, que DSM conserve. Le fichier d’environnement, jetons inclus, reste donc sur le volume après la disparition du paquet — ce que la documentation doit dire, et qui fait échouer le tour suivant d’un banc qui ne les efface pas, pour des raisons appartenant au précédent.

Un compte DSM portant le nom de l’utilisateur du paquet est détruit avec lui. Le username de conf/privilege crée un utilisateur système à l’installation ; un administrateur du même nom est masqué par lui puis supprimé à la désinstallation.

Le répertoire du pare-feu est /usr/local/etc/services.d/ — au pluriel. Le guide développeur dit service.d, qui n’existe pas. Le worker port-config acquiert après postinst, donc le port de l’assistant atteint bien l’entrée pare-feu dès l’installation.

port-config et usr-local-linker acquièrent quand le paquet est activé, pas quand postinst tourne : vérifiés plus tôt, ils sont toujours absents.

L’unité générée n’a pas de Restart=Type=oneshot, RemainAfterExit=yes, TimeoutStartSec=3600. DSM ne relance pas le processus s’il meurt.

postinst tourne aussi à une mise à jour, et il tourne avant postupgrade. Donc « le fichier d’environnement est absent » n’est pas la même question que « c’est une installation neuve » : sur une mise à jour où etc/ n’a pas survécu, y écrire les valeurs par défaut détruit le port et les jetons de l’utilisateur avant que la restauration ne tourne. postinst consulte $SYNOPKG_TEMP_UPGRADE_FOLDER avant de décider. Trouvé en simulant ce cas précis, pas en lisant la séquence documentée.

Les preuninst/postuninst de l’ancienne version tournent pendant une mise à jour. Tout ce qu’ils ont de destructeur tourne donc à chaque mise à jour — et le premier .spk publié est celui dont les scripts de désinstallation tourneront pendant la première mise à jour de tout le monde. Ils ne peuvent pas être corrigés après coup.

status qui renvoie 1 veut dire « planté, pidfile resté », pas « arrêté ». Un paquet proprement arrêté, c’est 3. Renvoyer 1 dit à Package Center que le service est mort.

prestart tourne au boot, et DSM l’appelle que vous l’ayez écrit ou non — precheckstartstop vaut "yes" par défaut. Un case qui sort non-zéro sur un verbe inconnu empêche le paquet de démarrer après un reboot pour toujours, avec un symptôme (« marche à la main, jamais après un reboot ») qui ressemble à tout sauf à un bras de case manquant.

Les scripts de cycle de vie ne sont pas root. run-as: package les gouverne, pas seulement le service — donc un chown hors de l’arbre du paquet, ou synopkghelper, échoue, possiblement en silence.

data-share tourne au démarrage du paquet, pas à l’installation, donc rien dans postinst ne peut supposer que le dossier partagé existe. Et un nom d’utilisateur qui ne correspond pas à sa liste de permissions crée le partage et l’accorde à personne, sans un mot.

Une strophe logrotate sans copytruncate arrête silencieusement la journalisation : log::init ouvre le fichier une fois et ne le rouvre jamais, donc une rotation déplace l’inode sous un serveur qui continue d’écrire dans un fichier sans nom.

Un .spk dont le tar externe est gzippé est rejeté avec « invalid file format » et rien de plus. Idem pour un qui embarque des membres ._ de macOS. check-spk.sh vérifie les deux.

Il existe exactement une route vers le port 69 sur DSM 7, et c’est setcap. Les quatre ont été essayées sur une machine 7.2.2 le 2026-08-27, parce que l’affirmation « DSM 7 n’autorise pas un paquet non signé à tourner en root » traînait dans CLAUDE.md depuis un moment sans mesure derrière — vraie, mais par chance.

RouteRésultat
"defaults": {"run-as": "root"} dans conf/privilegerefusée — erreur synopkg 319, invalid package privilege content, stage: install_failed
"ctrl-script": [{"action":"start","run-as":"root"}] — la forme qu’utilisent les paquets de Synology (FileStation, QuickConnect et StorageManager tous les trois)refusée, même erreur 319
cap_net_bind_service embarquée en attribut étendu security.capability dans package.tgzs’installe très bien — le format pax interne est accepté — mais Package Center supprime l’attribut, et getcap revient vide
setcap cap_net_bind_service=+ep sur le binaire installé, en root, après l’installationfonctionne ; le paquet ouvre alors udp/69 sous son propre utilisateur non privilégié, à côté de 8000 et 8001

net.ipv4.ip_unprivileged_port_start n’existe pas sur ce noyau, donc cette route est fermée aussi. /volume1 est en btrfs avec nodev mais pas nosuid, donc les capacités de fichier y fonctionnent bien, et /usr/bin/setcap existe en mode 0700.

Le root sur DSM 7 est conditionné au fait d’être un paquet Synology, et libsynopkg.so.1 le dit noir sur blanc. Lire ses chaînes sur une machine 7.2.2 transforme la mesure ci-dessus en explication. Un paquet qui ne passe pas le contrôle de signature (verifyPackageSignature vit dans la même bibliothèque) se voit refuser tout ceci :

Failed to pass privilege check, ctrl-script and executable section should not exist
Failed to pass privilege check, defaults should be provided and defaults.run-as should be package
Failed to pass privilege check, join-groupname should not contains admin group
Failed to pass privilege check, tool capabilities should not exist
Failed to pass privilege check, tool user should be package
Failed to pass privilege check, non-synology package should not use privilege migration

D’où le fait que FileStation, StorageManager, QuickConnect et SecureSignIn portent tous "ctrl-script": [{"action": "start", "run-as": "root"}] dans leur propre conf/privilege et que nous ne le pouvons pas : la forme est légale, c’est la signature qui la rend légale pour eux.

La ligne la plus importante est tool capabilities should not exist. Le format de privilège de DSM a un champ capabilities natif — documenté comme "capabilities": "cap_chown,cap_net_raw" sur une entrée tool depuis 7.0-40656, et SYNOPackageTool::Privilege::ChangeCapabilities est bien là dans la bibliothèque. Un paquet signé déclare cap_net_bind_service et n’a jamais besoin de setcap. Le mécanisme que nous voulons existe, est documenté, et nous est fermé.

Le guide développeur de Synology énonce la règle sans détour : « If you are developing a package with root privilege, you are not able to install that package unless it is signed by synology. » C’est donc leur signature, pas celle d’un éditeur tiers de confiance — ce qui tranche ce que la chaîne de la bibliothèque laissait ouvert. SynoCommunity a heurté le même mur (spksrc#4170, #4215).

Il existe un contournement documenté, et ce n’est pas une voie de distribution : un jeton de développement. On génère debug.dat depuis Centre d’assistance → Services d’assistance, on l’envoie à Synology, on reçoit un jeton signé, on le dépose dans /var/packages/syno_dev_token. Il n’est valable que sur le NAS qui a produit le debug.dat : livrer ainsi voudrait dire que chaque utilisateur fasse un aller-retour avec Synology avant de pouvoir installer. Un setcap est une commande locale, et c’est strictement mieux pour lui.

Conclusion, tranchée et non provisoire : le setcap manuel est le prix de ne pas être signé par Synology, et aucun changement d’empaquetage ne l’enlève. Si le paquet est un jour signé, l’étape manuelle et la tâche au démarrage sont remplacées par trois lignes dans conf/privilege.

setcap fonctionne aussi sur un DS416j, et ce n’était pas acquis. Les quatre routes vers le port 69 ont été mesurées sur une VM 7.2.2, qui est en x86_64 avec /volume1 en btrfs monté nodev mais pas nosuid — or un volume monté nosuid fait ignorer les capacités de fichier par le noyau, ce qui aurait fermé la dernière route ouverte sur la seule machine pour laquelle ce projet existe. Mesuré sur le DS416j (ARMv7, armada38x) : la capacité tient, le paquet ouvre udp/69 sous son utilisateur non privilégié, et boot check rapporte 0.0.0.0:69 handed over ipxe-arm64.efi — une vraie requête de lecture à laquelle on a répondu avec de vraies données.

La capacité appartient au fichier, donc une mise à jour la perd. Une nouvelle version remplace le binaire et la capacité part avec l’ancien — d’où la tâche au démarrage du Planificateur de tâches documentée par le paquet plutôt qu’une commande unique, et d’où le fait qu’un bind TFTP raté ne soit pas fatal : quand il l’était, cette mise à jour coupait aussi le point d’entrée des réponses.

Lier n’est pas un contrôle de santé, et cela prouve le contraire de ce qu’on croit. Un bind qui réussit sur le port TFTP signifie que personne n’écoute — l’état dégradé, pas l’état sain — et un bind qui échoue ne distingue pas ce serveur d’un autre service qui squatterait le port, puisque les deux donnent AddrInUse. boot check envoie donc une vraie requête de lecture et rapporte ce qu’obtiendrait une machine. Sa première version annonçait « already in use — that is this server, if it is running » et un test avec un squatteur sur le port a montré tout de suite que c’était une supposition.

Un nouveau réglage n’atteint jamais une installation qui existe déjà, sauf si quelque chose l’y met. Le fichier d’environnement vivant n’est écrit que s’il est absent — ce qui est correct, une mise à jour ne doit jamais remplacer le port et les jetons de quelqu’un par des valeurs par défaut — mais à lui seul cela rend une nouvelle fonctionnalité invisible pour toute installation antérieure. Le boot media est arrivé avec les dossiers créés, les chargeurs déposés et 69/udp enregistré au pare-feu, et RESCRIPTUM_BOOT_DIR jamais posé : boot check répondait « boot assets are off » sur un DS416j où tout le reste était en place. Comme etc/ survit à une désinstallation, même désinstaller et réinstaller n’y change rien. Le .env.example n’aide pas : rien n’oblige personne à le lire.

postinst ajoute désormais les clés dont le fichier vivant n’a jamais entendu parler, sans toucher à ce qui est présent. Une clé commentée compte comme présente, et c’est là la propriété de sûreté : c’est ainsi qu’un exploitant dit « celle-là je la connais et je n’en veux pas ». Supprimer une ligne veut dire « jamais entendu parler » et la fait revenir ; la commenter veut dire non, et c’est respecté.

L’application de bureau DSM

Huit choses, mesurées sur une machine virtuelle DSM 7.2.2 et sur un DS416j en 7.1.1, et aucune dans le guide du développeur.

Un défaut calculé à l’exécution doit l’être aussi dans settings(). Le panneau rend le défaut d’une variable comme valeur du champ ; un défaut qui n’existe que là où le serveur le consomme s’affiche donc en case vide — pendant que le serveur, lui, tourne sur une adresse qu’il a déduite et jamais montrée. RESCRIPTUM_PUBLIC_HOST est parti comme ça : l’exploitant n’avait aucun moyen de voir vers quelle adresse ses machines seraient envoyées, sinon en lisant le journal de démarrage. Deux entrées de KNOWN sont dans ce cas, et toutes deux ont leur branche dans settings() : le nombre de threads et l’hôte public. Une troisième demanderait le même traitement, et rien dans le typage ne le dit.

Un CGI sous /webman/3rdparty/<pkg>/ tourne sous le propriétaire du script. Pas en http, et pas en root — sous celui qui possède le fichier. DSM attribue l’arborescence d’un paquet à l’utilisateur du paquet : le backend de l’application tourne donc en rescriptum et peut lire le fichier d’environnement en 0600 qu’il possède, ce qui est toute la raison pour laquelle la configuration reste modifiable pendant que le serveur est arrêté. Prouvé en attribuant le même script de deux façons et en regardant id changer. Un script resté possédé par root, lui, tourne bien en root là-bas : n’en laissez pas traîner.

Ce chemin n’est pas authentifié par DSM. Une requête non authentifiée atteint le script et reçoit 200. Ce qui garde le CGI d’un paquet, c’est le paquet qui l’a écrit — ici authenticate.cgi plus un contrôle administrators, et en perdre un serait silencieux.

su dans un CGI bloque la requête. Sans </dev/null, il hérite du stdin du CGI — un tube venant du serveur web que rien ne fermera —, y lit, et ne revient jamais. La page d’état s’arrêtait simplement en plein milieu. Puis, une fois cela corrigé, il échouait quand même avec « Permission denied », un processus non root ne pouvant devenir personne. Les deux étaient du travail perdu : le script est déjà l’utilisateur en question, donc un simple test -r était la réponse depuis le début.

Le framework qu’un paquet peut utiliser, c’est la machine qui le décide, pas le guide de Synology. DSM 7.2 embarque un framework Vue et le guide actuel ne documente que celui-là. Le DS416j plafonne en DSM 7.1.1, où Vue n’existe pas : une application bâtie dessus s’installe et donne à cette machine une icône qui n’ouvre rien. ExtJS est présent sur les deux (7.1.1 et 7.2.2 mesurés), d’où une seule application au lieu de deux.

L’exemple ExtJS du guide ne tourne pas. Il déclare les classes avec Ext.define et enchaîne avec callParent ; face à SYNO.SDS.AppInstance cela lève Cannot read properties of null (reading 'apply') avant que la fenêtre n’apparaisse. C’est ExtJS 3.4.1 avec une couche Ext.define par-dessus : utiliser Ext.define pour la déclaration — le lanceur de DSM trouve la classe ainsi, et superclass est bien posé — puis appeler MaClasse.superclass.constructor.call(this, config) plutôt que callParent.

La barre des tâches de DSM appelle getWindowTitle() sur la fenêtre. Sans titre, elle lève une exception depuis le propre code de DSM, et l’application ne s’ouvre pas du tout — avec une trace qui accuse Synology et pas vous.

Ne jamais nommer une méthode show. Ext.Window.prototype.show() est ce que DSM appelle pour afficher la fenêtre : un show(which) ajouté pour changer d’onglet l’a écrasé silencieusement. La fenêtre était construite, mise en page, capable de rendre une miniature correcte dans l’aperçu de la barre des tâches — et n’apparaissait jamais. Rien ne levait d’exception, sur aucune des deux versions de DSM, et c’est ce qui a coûté cher : trouvé en bisectant depuis l’exemple minimal du guide. Tout ce qu’on ajoute à ce prototype partage l’espace de noms de chaque méthode d’Ext.Window, qui est vaste.

fieldLabel est dessiné par la mise en page « form », pas par le champ. Un syno_displayfield dans un Ext.Panel ordinaire affiche sa valeur et perd son libellé sans rien dire, ce qui transformait la page d’état en colonne de valeurs nues. Il faut SYNO.ux.FormPanel, ou layout: 'form'.

Builds reproductibles et caches de navigateur ne s’entendent pas, et c’est le navigateur qui gagne. make-spk.sh fixe le mtime de chaque fichier empaqueté pour que les mêmes entrées produisent un .spk identique octet pour octet. nginx en fait un Last-Modified: 2019 sans Cache-Control, et la fraîcheur heuristique d’un navigateur est un dixième de l’âge apparent du fichier — des années. Un paquet mis à jour a continué de faire tourner l’ancien JavaScript contre le nouveau backend, malgré une réinstallation et un rechargement forcé. Le fichier de l’application porte donc le numéro de version dans son nom, et tout ce qu’elle va chercher elle-même porte ?v= ; check-spk.sh vérifie que le nom bouge toujours. deux.

Changements de comportement à retenir

Les documents de réponse doivent maintenant être valides. Avant la fusion, ils étaient servis comme des octets opaques, donc un document malformé atteignait l’installateur ; maintenant c’est un 500 avec l’erreur de parsing dans le log. C’est le meilleur échec, mais c’est un changement de comportement — des fixtures écrites en pseudo-YAML ont cessé de fonctionner à ce moment-là.

{{ 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 couvrir. Utilisez là un fait de requête comme {{ mac }}.

Le site de documentation

Le site de documentation

Ce site est docs/ dans le dépôt, rendu par notabene et publié sur GitHub Pages. Le binaire Rust n’en sait rien ; la chaîne d’outils de doc, c’est un package.json et un fichier de configuration, et la retirer laisserait docs/ comme du Markdown parfaitement lisible.

Pourquoi un site et pas un README plus long

Le README avait atteint 28 Ko et était trois documents portant un seul manteau : une présentation, un manuel utilisateur, et une note d’architecture. Un lecteur cherchant l’étape du pare-feu DSM devait faire défiler la sémantique de fusion. Donc :

  • docs/guide/ — utiliser rescriptum : installer, écrire des réponses, l’exploiter en production.
  • docs/development/ — construire rescriptum : les contraintes, les internes, la release.
  • README.md — ce que c’est, une démonstration de 30 secondes, et des liens vers le site.

Les deux espaces ont des publics différents et aucune raison de s’entremêler.

Deux langues

Le site est bilingue : l’anglais est la source, le français en est une traduction. La stratégie i18n suffix fait que les fichiers anglais gardent leurs chemins et leurs URL, et que les français sont des voisins *.fr.md :

docs/guide/answers/grouping.md      → /guide/answers/grouping
docs/guide/answers/grouping.fr.md   → /fr/guide/answers/grouping

Cet agencement a été choisi plutôt qu’un dossier par langue parce qu’il s’ajoute à une doc existante sans rien déplacer — les URL anglaises et leurs fils de commentaires survivent.

Les règles qui en découlent :

  • Écrire l’anglais d’abord, puis traduire. Un changement sur une page anglaise qui n’est pas répercuté laisse la page française périmée plutôt que cassée ; le lecteur retombe dessus avec un bandeau.
  • Les liens gardent le nom de base. Depuis une page française, écrivez ./selection.md, pas ./selection.fr.md — notabene résout la langue. En revanche, les ancres doivent être le slug du titre français : ./templating.md#machine-exige-un-document-machine.
  • Les commentaires sont par langue. Un commentaire laissé sur la page française est son propre fil et pointe sur le fichier source français.
  • L’habillage du site, la recherche et llms.txt sont également par langue.

README.md et README.fr.md suivent la même règle et se lient l’un à l’autre.

Travailler sur la doc

npm install          # une fois
npm run docs         # → http://localhost:3009

Cela ouvre le site avec la boucle de revue activée : sélectionnez n’importe quel texte sur la page rendue et laissez un commentaire exactement là où est le problème. Le commentaire ancré est l’instruction — pas besoin de citer un passage dans une fenêtre de chat en espérant que l’agent le retrouve.

Dites ensuite à votre agent « traite les commentaires de la doc ». Il lit docs/.notabene/, édite la source, marque chaque commentaire traité, et ajoute une entrée de journal disant ce qui a changé et pourquoi.

ScriptRôle
npm run docsle serveur de revue, avec rechargement à chaud
npm run docs:buildle site statique public dans ./_site
npm run docs:previewservir ce qui a été construit
npm run docs:lintvalider chaque lien interne contre les routes émises par le dernier build
npm run docs:status / docs:stopgérer un serveur de dev détaché

Vous utilisez Claude Code ? /plugin marketplace add z29k/notabene puis /plugin install notabene@z29k, et dites « set up notabene ». Le plugin lance son propre renderer épinglé, donc il n’entre pas en conflit avec celui du package.json.

Le mode de revue est approve

notabene.config.mjs définit review: "approve", donc l’agent propose au lieu de résoudre : chaque édition est validée contre son vrai diff git sur /review avant que le commentaire ne soit clos. Une documentation qui décrit des mots de passe root et de la configuration de démarrage mérite d’être lue avant d’être publiée. Passez à "auto" si cette cérémonie ne se justifie pas.

Écrire une page

Chaque page est du CommonMark avec un frontmatter YAML optionnel :

---
title: Groupes et fusion
description: Une phrase — elle devient la meta description et l'extrait de recherche.
sidebar:
  label: Groupement       # texte de la barre latérale, si le titre est trop long
  order: 3                # position parmi les frères, croissante
---

Tout a une valeur par défaut : une page sans frontmatter se rend très bien, triée alphabétiquement. Un dossier est nommé et positionné par son index.md.

Conventions dans ce dépôt :

  • Liens relatifs entre pages, avec l’extension .md./selection.md, ../reference/configuration.md. Ils deviennent des routes sur le site et restent cliquables sur GitHub.
  • URL GitHub absolues pour les fichiers du dépôtanswers/, CLAUDE.md, un workflow. Ils sont hors de docs/ et n’ont pas de route.
  • Diagrammes Mermaid rendus nativement, dans une clôture ```mermaid — voir architecture et le cycle de vie d’une requête.
  • Chaque page a besoin de son voisin *.fr.md, avec le frontmatter traduit aussi — le title, la description et le sidebar.label sont tous visibles par le lecteur.

Configuration

notabene.config.mjs à la racine du dépôt. Les parties qui comptent :

roots: [
  { key: "guide",       label: "Guide",                              path: "docs/guide" },
  { key: "development", label: { en: "Development", fr: "Développement" }, path: "docs/development" },
],
store: "docs/.notabene",
home: { en: "docs/home.md", fr: "docs/home.fr.md" },
i18n: { locales: ["en", "fr"], defaultLocale: "en", strategy: "suffix" },
branding: {
  logo: "assets/rescriptum-logo.jpg",
  favicon: "assets/rescriptum-logo.jpg",
  socialImage: "assets/rescriptum-logo.jpg",
},
editPattern: "https://github.com/z29k/rescriptum/edit/develop/{path}",
review: "approve",
publish: { site: "https://z29k.github.io", base: "/rescriptum" },

Chaque chaîne visible par le lecteur accepte une map par langue — le label et la description d’un espace, la page d’accueil, chaque libellé de lien de navigation, le titre du bloc latéral, le pied de page. Non définie pour une langue, elle retombe sur la langue par défaut.

Le logo est assets/rescriptum-logo.jpg : un rescrit scellé sur une disquette — une réponse écrite, remise par une machine. Il sert de logo dans la barre du haut, de favicon et de carte sociale, et c’est aussi l’image qu’utilise le README.

editPattern pointe sur develop, pas main : la doc y est fusionnée comme tout le reste, et main ne reçoit que des commits de release.

docs/.notabene/ est le store de commentaires et de journal — du JSON simple, commité, diffable dans une PR. Commitez-le.

Le garde-fou CI

.github/workflows/ci.yml a un job docs : npm ci, construction du site public, puis notabene lint, qui vérifie chaque lien interne contre les routes que le build a réellement émises et suggère les quasi-correspondances. Un lien mort dans de la documentation publiée est bon marché à empêcher et gênant à livrer.

Il tourne sur les mêmes pushes que les garde-fous Rust.

Publication

.github/workflows/docs.yml construit en --public et déploie sur GitHub Pages à chaque push sur main touchant docs/, la configuration ou le workflow — plus workflow_dispatch, pour publier une correction de doc sans attendre une release.

Comme main ne reçoit que des commits de release, la documentation part normalement avec une release. Lancez le workflow à la main quand elle ne doit pas attendre.

L’artefact est le build public en lecture seule : pas d’interface de revue, pas de données de store, plus llms.txt, un jumeau Markdown par page, un sitemap et des métadonnées OpenGraph. pagefind est une dépendance de développement, donc npm ci donne au site une recherche plein texte sans configuration supplémentaire.

Mise en place unique du dépôt : Settings → Pages → Source = GitHub Actions.

La situation des dépendances

npm audit signale des avis de sécurité dans Astro, esbuild et sharp, transitivement sous notabene, sans correctif disponible en amont pour l’instant. Ils sont limités au développement : rien de node_modules n’est exécuté par le site publié ni n’atteint le binaire Rust, et le job CI construit du HTML statique à partir de Markdown que ce dépôt possède.

À revérifier quand notabene se met à jour, pas à bloquer dessus.