rescriptum
Développement
an answer written for this machine
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
| Chemin | Contient |
|---|---|
src/main.rs | mise en place du runtime, boucle d’accept, service des connexions, routage, et la moitié bloquante d’une requête |
src/lib.rs | la crate. main.rs est un binaire mince par-dessus, pour que le comportement soit testable directement |
src/select.rs | normalisation, correspondance, superposition — le comportement qui compte |
src/facts.rs | ce qu’une requête dit de la machine |
src/format/ | une interface par format de document ; xml.rs porte l’arbre XML |
src/merge.rs | la fusion TOML, utilisée par format |
src/store/ | d’où viennent les documents : file.rs, sqlite.rs, derrière un trait mince |
src/admin.rs | l’API d’écriture, et la garantie qu’une écriture ne peut pas casser le parc |
src/config.rs | configuration par l’environnement |
src/envfile.rs | le fichier de valeurs par défaut que nomme RESCRIPTUM_ENV_FILE — jamais découvert, seulement nommé |
src/capture.rs | enregistrement de ce que les machines envoient réellement |
src/cli.rs | les sous-commandes render, check, import et export |
src/log.rs | une 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
auditde 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 scope —
feat(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
| Module | Possède |
|---|---|
main.rs | le runtime tokio, la boucle d’accept, le sémaphore de connexions, les deux timeouts, le routage, la vérification du jeton de réponse, et l’appel spawn_blocking qui fait la recherche |
facts.rs | transformer une requête en valeurs étiquetées — paramètres de query, corps JSON aplati, segments de chemin, et la botte de foin normalisée |
select.rs | le comportement qui compte : normalisation, scoring, chaîne de groupes, ordre de fusion, remplissage de templates, et le listing mis en cache |
format/ | une interface par format de document. Doc parse, fusionne, rend, et signale ses clés de contrôle |
merge.rs | la fusion profonde TOML, utilisée par format |
store/ | d’où viennent les documents, derrière un trait de lecture à deux méthodes |
admin.rs | son propre listener, l’auth bearer, le garde-fou d’échecs, et l’annulation qui empêche une écriture de casser le jeu de réponses |
config.rs | l’environnement, et la validation qui transforme une configuration dangereuse en erreur de démarrage |
envfile.rs | le fichier que nomme RESCRIPTUM_ENV_FILE : parsé, jamais découvert, et fatal s’il est illisible |
cli.rs | render, check, import, export |
capture.rs | l’enregistrement des corps de requête |
log.rs | une ligne par événement, des horodatages UTC calculés sans crate de date, et les deux réglages au-dessus : ce qui est gardé, et où cela va |
La seule frontière qui mérite d’être défendue
Le store est délibérément mince. Il rend le texte brut des documents et un jeton de version bon marché, et ne décide de rien :
pub trait Store: Send + Sync {
fn version(&self) -> Version; // assez peu cher pour être appelé par requête
fn snapshot(&self) -> io::Result<Snapshot>; // seulement quand la version a bougé
fn describe(&self) -> String;
}
Chaque décision — correspondance, chaînes extends, fusion, rendu, check — vit au-dessus,
dans select.rs et merge.rs, et est partagée par les deux backends. Dès qu’un backend se
met à décider du comportement, les deux divergent.
tests/stores.rs est ce qui fait de cela une garantie plutôt qu’une intention : chaque cas de
comportement tourne deux fois, une par store, et affirme le résultat identique.
La moitié écriture est un trait séparé, parce que servir des réponses n’en a jamais besoin :
pub trait StoreWrite: Store {
fn put_machine(&self, id: &str, format: &str, body: &str) -> io::Result<()>;
fn delete_machine(&self, id: &str, format: &str) -> io::Result<bool>;
// …groupes, défaut
}
Notez que chaque opération nomme un format. Un document est indexé par ce à quoi il sert — une machine et un système d’exploitation — pas par identifiant seul.
La couche de cache
Answers enveloppe un store et garde un Listing parsé et fusionné derrière un mutex :
struct Cached { version: Version, loaded_at: Instant, listing: Arc<Listing> }
Une requête réutilise le cache seulement quand les trois conditions tiennent :
store.version()est inchangée — pour les fichiers, le mtime du répertoire ; pour SQLite, un atomique en mémoire ;- cette version est
Some— une version illisible n’est jamais traitée comme « inchangée » ; - moins de
RELOAD_BACKSTOP(1 s) s’est écoulé.
Le filet n’est pas redondant avec la vérification de version. Éditer le contenu d’un fichier de groupe ne bouge aucun mtime de répertoire, et un changement fait par un autre processus ne bouge aucun atomique en mémoire. Sans le filet, l’une ou l’autre édition serait invisible jusqu’à ce qu’autre chose arrive au répertoire.
Un mutex empoisonné — une autre requête a paniqué en plein rafraîchissement — est récupéré plutôt que propagé. Les données en cache sont encore structurellement saines, et faire échouer une installation à cause du panic d’une autre requête serait le mauvais compromis.
Pourquoi il n’y a pas de framework
Le routage ici est un seul if sur la méthode et le chemin. Un framework n’apporte rien
pour ça, et axum en particulier ne donne aucun moyen de définir un délai de lecture des
en-têtes — précisément le garde-fou anti-slowloris qui a motivé le passage à l’asynchrone.
Donc : hyper directement.
Dépendances
64 crates, 2,4 Mo statique sur ARMv7 (1,3 Mo sans SQLite). Directes :
| Crate | Pour |
|---|---|
tokio | le runtime, les timers, les signaux |
hyper + hyper-util + http-body-util | HTTP/1, avec un délai de lecture des en-têtes |
toml_edit | TOML, en préservant la mise en forme |
serde_json | documents JSON, et aplatissement d’un corps de requête |
serde_yaml_ng | documents YAML |
quick-xml | documents XML |
rusqlite (optionnelle, bundled) | le store SQLite |
Aucun serde derive nulle part. La règle d’origine était « ne jamais parser le corps de
requête comme du JSON » ; elle a été assouplie délibérément, et l’énoncé honnête de l’état
actuel est : le corps est parsé en une serde_json::Value non typée quand il se trouve
être du JSON, uniquement pour en récolter des faits. Rien n’est désérialisé dans une struct,
donc aucune hypothèse sur le schéma de Proxmox n’est gravée dans un type. Un corps qui n’est
pas du JSON n’est pas une erreur — il apporte la botte de foin et rien de plus. Voir
sélection.
Ajouter une dépendance exige une raison dans le message de commit. Ce binaire tourne en root sur le matériel d’autres gens.
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 si | une 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émarrage | refuser 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-fou | Couvre |
|---|---|
http1::Builder::header_read_timeout | un client qui ouvre une connexion et distille ses en-têtes |
tokio::time::timeout autour de la connexion entière | tout 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_timeoutexige.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 :
GET /health→200 OK. Avant l’authentification, avant tout, pour que la supervision ne s’éteigne jamais.- Le jeton de réponse, quand
RESCRIPTUM_ANSWER_TOKENest 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. - La méthode — tout ce qui n’est ni
GETniPOSTdonne405. Content-Length— une taille annoncée aberrante est refusée depuis l’en-tête, plutôt qu’en laissantLimitedsauter après avoir tamponné un mégaoctet.- Le corps, via
Limited::new(…, MAX_BODY). Une erreur de limite de longueur devient413, toute autre400.
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() :
- demande au store sa
version()— unstatpour les fichiers, une lecture atomique pour SQLite ; - réutilise le
Listingen cache, ou prend un nouveausnapshot()et le reconstruit ; - choisit le meilleur document machine et le meilleur groupe (scoring) ;
- résout
extends, à l’intérieur d’un format ; - fusionne chaîne de groupes → document machine ;
- remplit les
{{ placeholders }}; - retire les clés de contrôle ;
- rend.
5. Réponse
| Résultat | Ré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_patternest l’autre. La normalisation ordinaire retire*et?avec le reste de la ponctuation, ce qui transforme chaque glob en littéral — en silence. Les motifs de sélecteur doivent passer parnormalize_pattern, qui les conserve.
Faits
Facts est une map étiquette → valeurs, plus la botte de foin. Trois sources, étagées du plus
structuré au moins structuré :
Les paramètres de query — parsing fait main avec décodage pourcent, plutôt que de tirer une
crate d’URL pour vingt lignes de travail. Les valeurs vont aussi dans la botte de foin,
pour qu’un document nommé d’après une MAC résolve que la MAC soit arrivée dans un corps POST
ou dans une query string. Sans cela, un GET — qui n’a aucun corps — ne pourrait jamais
correspondre par nom.
Le chemin apporte trois étiquettes synthétisées :
| Étiquette | Depuis |
|---|---|
path | le chemin entier, débarrassé de ses slashes |
file | son dernier segment |
segment | chaque segment, comme valeurs séparées |
file n’est pas de la décoration : la source de données NoCloud de cloud-init récupère
user-data et meta-data depuis une seule URL et ignore complètement la source si l’un
des deux manque, donc le même serveur doit leur répondre différemment. Les segments de chemin
alimentent aussi la botte de foin, parce que NoCloud peut développer
__dmi.chassis-serial-number__ dans l’URL.
Le corps JSON, aplati par flatten() en à la fois ses chemins pointés complets et ses
noms de feuilles nus. Les indices de tableau font partie du chemin mais pas du nom de feuille,
donc network_interfaces.0.mac est aussi atteignable par mac tout court.
L’écart avec la règle d’origine
La règle initiale était que le corps de requête n’est jamais parsé comme du JSON. Elle a été assouplie, délibérément et étroitement :
if let Ok(value) = serde_json::from_slice::<serde_json::Value>(body) {
flatten(&value, &mut String::new(), &mut facts);
}
Non typé, opportuniste, et non fatal — un corps qui n’est pas du JSON apporte simplement la botte de foin et rien de plus. Aucune struct n’est dérivée, donc aucune hypothèse sur le schéma de Proxmox n’est gravée dans un type.
C’est la forme « nom de feuille » qui justifie tout cela. La documentation de Proxmox
avertit elle-même que le contenu de dmi « peut varier énormément selon le système ». Un
numéro de série ne peut être atteint d’aucune autre façon, parce que l’URL gravée dans une ISO
est la même pour toutes les machines. Un sélecteur disant « un champ nommé serial, où qu’il
se trouve » survit à une réorganisation qu’un chemin figé ne supporterait pas.
Scoring
const IDENTITY_SCORE: u32 = 1_000;
fn score(control: &Control, identity: &[String], facts: &Facts) -> Option<u32> {
if identity.iter().any(|n| !n.is_empty() && facts.haystack().contains(n)) {
return Some(IDENTITY_SCORE); // nommer une machine est le plus spécifique possible
}
if control.matchers.is_empty() { return None; }
control.matchers.iter()
.all(|(k, p)| facts.matches(k, p))
.then_some(control.matchers.len() as u32)
}
identityest le radical normalisé pour un document machine, et lesmembersnormalisés pour un groupe.- Tous les critères doivent tenir ; le score est leur nombre.
IDENTITY_SCOREvaut 1000 plutôt queu32::MAXpour que « une correspondance d’identité bat n’importe quel sélecteur » reste lisible, et qu’un sélecteur à mille critères reste un problème théorique plutôt qu’un problème subtil.
Les égalités se départagent sur le nom trié, le premier par ordre alphabétique :
.max_by(|(a, ca), (b, cb)| a.cmp(b).then_with(|| cb.id.cmp(&ca.id)))
La comparaison interne inversée est ce qui fait préférer à max_by le plus petit nom.
matchbox, l’antécédent le plus proche, documente que sa propre résolution entre groupes
concurrents « ne sera pas déterministe ». Celle-ci l’est, et un test l’épingle.
Filtrage par format
fn wanted(facts: &Facts) -> Option<&'static [&'static str]> // depuis les faits `segment`
fn acceptable(wanted: Option<&…>, format: &str) -> bool // None ⇒ tout peut répondre
Le filtrage porte sur l’extension, jamais sur la famille. .ks et .preseed sont tous
deux Kind::Text ; filtrer par famille laisserait un preseed répondre à /rhel/ks.
None — une URL ne nommant aucun alias — ne contraint rien, ce qui garde /answer fonctionnel
pour un déploiement qui ne sert jamais qu’un format.
Le cache du listing
struct Cached { version: Version, loaded_at: Instant, listing: Arc<Listing> }
Réutilisé seulement quand la version() du store est inchangée, vaut Some, et que moins
de RELOAD_BACKSTOP (1 s) s’est écoulé.
La lecture littérale de la spécification — relire le répertoire à chaque requête — c’est un
readdir plus un tri plus une passe de normalisation par requête. Avec un document de réponse
par machine, le débit s’effondre :
| Documents | Relecture littérale | Cache par mtime |
|---|---|---|
| 10 | 11 954 req/s | 12 922 req/s |
| 200 | 3 198 req/s | 12 890 req/s |
| 2 000 | 311 req/s | 12 520 req/s |
| 10 000 | — | 6 924 req/s |
Un stat remplace tout le parcours, et une nouvelle machine est quand même prise en compte
sans redémarrage — ce qui est la garantie que la spécification voulait réellement. Les
identités normalisées sont calculées une fois par lecture du store, pas une fois par requête.
Le filet n’est pas redondant, et il travaille plus qu’avant. Éditer le contenu d’un document ne bouge aucun mtime de répertoire ; en ajouter un à l’intérieur du répertoire d’une machine non plus, puisque c’est un niveau sous le mtime surveillé ; et un changement fait par un autre processus ne bouge aucun atomique en mémoire. Avec un répertoire par identité, le filet est donc ce qui rattrape tout sauf l’apparition ou la disparition d’une identité. Des tests couvrent chaque cas.
Les chiffres ci-dessus ont été mesurés contre l’agencement plat. La lecture elle-même est désormais un
readdirpar identité en plus du fichier qu’elle ouvrait déjà — de 28 ms à 63 ms à 2 000 machines — que le cache amortit sur une seconde de requêtes, et qui n’a pas déplacé le débit de façon mesurable. Cela reste la raison pour laquelle un groupe vaut mieux qu’un répertoire par machine.
Le coût restant à 10 000 documents est un balayage linéaire d’aiguilles précalculées — du CPU pur, aucun appel système. Regrouper les aiguilles par longueur et faire glisser une fenêtre sur le corps supprimerait ce coût, mais un déploiement de 10 000 machines se termine déjà en moins de deux secondes. Mesurez avant d’ajouter cela.
Construire un Listing
build(snapshot) fait tout ce qui est coûteux, une fois :
- parser chaque document, en gardant l’erreur plutôt qu’en faisant échouer le chargement ;
- normaliser chaque radical et chaque entrée de
members; - résoudre les chaînes
extends, en détectant cycles et parents manquants — le groupe cassé est écarté plutôt qu’appliqué à moitié, et le problème est enregistré ; - pré-fusionner la chaîne de chaque groupe, et la pré-rendre comme chaîne de caractères quand elle ne porte aucun placeholder.
Ce dernier point est pourquoi le groupement est le chemin rapide : le cas courant en
datacenter ne parse rien par requête. Group::has_placeholders est le drapeau qui en décide.
problems est collecté ici, pas au moment de la requête, ce qui permet au
garde-fou d’annulation de l’API
d’administration d’attraper un extends cassé avant que quiconque ne le demande.
Résolution
resolve() est un match sur (machine, machine_doc, group) :
| Cas | Comportement |
|---|---|
| groupe seul | servir la chaîne préparée, ou cloner-remplir-nettoyer-rendre si templatisé |
| machine seule | remplir, nettoyer, rendre |
| les deux | chaîne de groupes, fusionner la machine par-dessus, remplir, nettoyer, rendre |
| aucun | retomber sur default pour le format demandé, qui peut lui-même extends un groupe |
Les variables de template sont les faits de la requête plus machine et group, que les
faits ne peuvent pas porter parce qu’ils ne sont connus qu’une fois la correspondance faite.
machinen’est lié que si un document machine a matché. Une machine revendiquée par lesmembersd’un groupe sans document à elle se résout avecmachine: None, donc{{ machine }}dans un groupe échoue pour exactement les membres qu’il devait servir. Le guide de templating dit d’utiliser un fait de requête à la place.
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 :
Kinddécide comment parser, comment fusionner, et leContent-Type.- L’extension décide si un endpoint peut être servi, et quel validateur
checkappelle.ksetpreseedsont tous deuxKind::Textmais ne partagent pas de validateur — ce qui est pourquoiResolutionporteformat_nameà côté deformat.
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.
seeda é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 / objets | fusionnent récursivement |
| Toute autre valeur | remplacée intégralement par la couche supérieure |
| Tableaux | remplacent, ils ne concatènent pas |
Kind::Text | concaté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. Leconfig: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 < 2 & 3revenait en123. 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
putanté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
readdirpar 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 :
| Build | Taille ARMv7 |
|---|---|
| par défaut | 2 103 456 octets |
--no-default-features | 944 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
extendsde machine pointant sur un groupe manquant est détecté au chargement, dansselect.rs, plutôt qu’au moment où cette machine demande. Le garde-fou ne peut attraper que ce queproblems()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 :
| Constante | Valeur |
|---|---|
MAX_FAILURES | 5 |
FAILURE_WINDOW | 60 s |
BASE_BLOCK | 60 s, doublant à chaque récidive |
MAX_BLOCK | 900 s |
MAX_TRACKED | 4096 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_TRACKEDest 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 /healthest 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
ECONNRESETau 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=tomlsur 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
| Suite | Cas | Pour |
|---|---|---|
tests/integration.rs | 52 | le vrai binaire sur une vraie socket |
tests/cli.rs | 65 | render, check, import, export, config et le fichier d’environnement — contre le vrai binaire |
tests/media.rs | 45 | les médias de démarrage contre le vrai binaire, les deux listeners debout |
src/config.rs | 56 | l’environnement, ce qui refuse de démarrer, et qui l’emporte du fichier ou de l’environnement |
tests/stores.rs | 48 | chaque comportement, contre les deux stores |
tests/tftp.rs | 30 | le 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.rs | 29 | normalisation, scoring, superposition, remplissage de templates |
src/format/mod.rs | 28 | parsing, fusion, clés de contrôle, alias d’endpoint |
tests/admin.rs | 26 | l’API d’administration de bout en bout, formats compris |
src/envfile.rs | 23 | le parseur et l’écrivain du fichier d’environnement, et ce que chacun refuse |
src/facts.rs | 22 | parsing de query, aplatissement JSON, globbing |
src/format/xml.rs | 18 | l’arbre XML — appariement, entités, fidélité |
src/merge.rs | 11 | la fusion profonde TOML |
tests/guards.rs | 7 | le jeton de réponse, et le verrouillage qui délibérément n’existe pas |
src/installed.rs | 6 | une machine qui signale son installation, et ce qu’il ne faut jamais désarmer |
src/log.rs | 15 | lecture des niveaux, et l’arithmétique d’horodatage |
src/boot/*.rs | 128 | le 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.rs | 21 | comportement 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 import → export 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-Lengthaberrant — 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
503immé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 testne reconstruit pastarget/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_lookupprend 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/seddans 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.
| Prouve | Coût | |
|---|---|---|
packaging/dsm/check-spk.sh | l’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 administrators | des secondes, à chaque push |
packaging/dsm/lifecycle-test.sh | tout 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 navigateur | des secondes, à chaque push |
packaging/dsm/vm/on-dsm.sh | la 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 paquet | des 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 :
| Job | Lance |
|---|---|
| gates | cargo fmt --all --check, cargo clippy --all-targets --all-features -D warnings, cargo test --all-features, cargo build --release --no-default-features |
| docs | construit le site public et lance notabene lint |
| audit | cargo audit --deny warnings sur l’arbre de dépendances |
| cross | un 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
| Cible | Pour | Croisé |
|---|---|---|
armv7-unknown-linux-gnueabihf | le DS416j, la raison d’être du projet — glibc, pas musl, voir plus bas | zigbuild, plancher 2.17 |
aarch64-unknown-linux-musl | NAS ARM récents, Raspberry Pi | zigbuild |
x86_64-unknown-linux-musl | la plupart des autres hôtes Linux | zigbuild |
aarch64-apple-darwin | développement local | natif |
x86_64-apple-darwin | développement local | natif |
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épertoire —
brew 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
| Build | ARMv7 |
|---|---|
| par défaut | 2 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
| Feature | Défaut | Apporte |
|---|---|---|
sqlite | activée | le 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.
| ABI | arch dans INFO | Depuis |
|---|---|---|
x86_64 | x86_64 — le nom de famille, donc toutes les plateformes Intel | x86_64-unknown-linux-musl |
armv7 | armada38x — le raccourci de famille n’atteint pas les plateformes Marvell | armv7-unknown-linux-gnueabihf |
aarch64 | armv8 | aarch64-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.
| Environnement | Défaut |
|---|---|
TARGET | armv7-unknown-linux-gnueabihf |
ANSWERS | <répertoire-distant>/answers |
PORT | 8000 |
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
| Branche | Règle |
|---|---|
main | stable. Seuls les commits de release et les tags vX.Y.Z y atterrissent. Ne poussez jamais de travail de fonctionnalité directement |
develop | inté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 :
- 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. - Compile en croisé les cinq cibles publiées.
- Empaquette chacune en
rescriptum-<version>-<cible>.tar.gz, avecREADME.mdetLICENSEà 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é. - 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 checksi 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, avecpackaging/ipxe/pour offre écrite. - Emballe les builds musl Linux en paquets Synology,
rescriptum-<version>-<build>-<abi>.spk, et contrôle structurellement chacun avant qu’il puisse être publié. - Crée la GitHub Release avec
ghet--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 checktraite 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éfinitRESCRIPTUM_BOOT_DIRdoit aussi définirRESCRIPTUM_TFTP_ADDR=off, sauf si la sonde est le sujet ;tests/tftp.rscouvre 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 < 2 & 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.
| Route | Résultat |
|---|---|
"defaults": {"run-as": "root"} dans conf/privilege | refusé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.tgz | s’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’installation | fonctionne ; 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.txtsont é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.
| Script | Rôle |
|---|---|
npm run docs | le serveur de revue, avec rechargement à chaud |
npm run docs:build | le site statique public dans ./_site |
npm run docs:preview | servir ce qui a été construit |
npm run docs:lint | valider chaque lien interne contre les routes émises par le dernier build |
npm run docs:status / docs:stop | gé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ôt —
answers/,CLAUDE.md, un workflow. Ils sont hors dedocs/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 — letitle, ladescriptionet lesidebar.labelsont 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.