24. Étendre le pipeline

Comme le pipeline est une machine à états sur une table unique, ajouter une étape de traitement ne touche ni l’ingress, ni le dispatcher, ni aucune autre étape : vous écrivez un nouveau programme d’étape et insérez sa section [stage-<name>] dans le graphe.

Un programme d’étape est un worker persistant que le dispatcher démarre comme PROGRAM [-c FILE] worker ; il lit un workqueue_id par ligne de l’entrée standard et écrit une ligne de statut par message sur la sortie standard (0 en cas de succès). Pour chaque identifiant il doit : charger la ligne, faire son travail et appeler exactement un helper terminal. worker est le seul mode d’exécution — il n’existe pas de forme positionnelle PROGRAM <workqueue_id> ; pour traiter un message à la main, dirigez son identifiant vers un worker (voir Tester une étape). Tout ce qui est partagé — la boucle worker stdin/stdout, l’ouverture unique du pool, le chargement de la ligne, la vérification de la section — est fourni par pepsi_common::stage (run_worker). La sortie standard est le canal de protocole du worker, un corps d’étape ne doit donc jamais y écrire (journalisez sur l’erreur standard).

24.1. Quand une étape est le bon outil

Écrivez une étape lorsque vous voulez inspecter ou transformer un message en transit, ou prendre une décision d’acheminement, par exemple :

  • un filtre de contenu (spam/virus) qui pause, fail ou avance ;

  • un réécriveur d’en-têtes ou un hook d’archivage/audit ;

  • une méthode de remise alternative (un nouveau transport) ;

  • une intégration qui fait appel à un autre service par message.

Si vous n’avez besoin que d’un câblage différent des étapes existantes, vous n’avez besoin d’aucun code — juste de nouvelles sections [stage-*].

24.2. Anatomie d’une crate d’étape

Suivez les étapes existantes (pepsi-stage-srs est la plus petite). Une étape est une crate de bibliothèque plus un binaire fin dans la racine de l’espace de travail :

  1. Crate de bibliothèque pepsi-stage-<name> avec :

    • constants.rs — un CONFIG_SOURCE et une chaîne PROGRAM (le nom de binaire que les opérateurs mettent dans PROGRAM =).

    • config.rs (facultatif) — une structure analysée depuis la Section de l’étape pour toute option spécifique au programme.

    • lib.rs — le corps d’étape async fn body(ctx: StageContext<'_>) -> anyhow::Result<()>, une constante LOAD, et l’unique point d’entrée fin worker (run_worker).

  2. Module de programme src/programs/stage_<name>.rs dans le paquet racine pepsi : les Args clap (#[clap(flatten)] common: PepsiArgs suivi de #[command(subcommand)] cmd: Command, dont la seule variante est Worker) et un pub fn main() qui appelle pepsi_main(CONFIG_SOURCE, args.common, …). Enregistrez le module dans src/programs/mod.rs.

  3. Binaire src/bin/pepsi-stage-<name>.rs — un mince fn main() { pepsi::programs::stage_<name>::main(); } avec une entrée [[bin]] portant required-features = ["multibin"] dans le Cargo.toml de l’espace de travail. C’est la disposition de développement ; le build de release plie au contraire le programme dans le binaire multi-appel pepsi, ajoutez donc aussi une branche à run_applet et un nom à APPLETS dans src/bin/pepsi.rs.

  4. Enregistrez la crate de bibliothèque dans le Cargo.toml de l’espace de travail et ajoutez le nom du programme à FOLDED_BINARIES dans le Makefile (ou à STANDALONE_BINARIES s’il doit porter son propre bit setuid/setgid, ce qu’un binaire partagé ne peut pas faire, ou s’il est déployé ou lancé séparément par exec).

  5. (Facultatif, étapes pliées uniquement.) Si l’étape est assez peu coûteuse pour valoir la peine d’être exécutée dans le processus worker d’une étape précédente, enregistrez-la dans le registre de fusion : un registry.insert(...) avec la macro fusion_entry! dans src/fusion.rs, dont le binaire unifié appelle l”install() une fois au démarrage. Le registre est l’ensemble des étapes dans lesquelles une autre étape peut être fusionnée ; une étape qui n’y figure pas n’est jamais fusionnée, quoi que dise le FUSION de sa section. La fusion exige que la crate exporte une constante publique LOAD et une fonction de corps publique, et elle est refusée à l’exécution à moins que le Load du successeur ne dépasse pas ce que l’étape précédente a déjà chargé.

24.3. Le corps

Un corps d’étape est uniforme. Le point d’entrée ne fait que nommer le corps et la quantité de message à charger ; le corps agit et se termine

// Load only what you touch. Metadata loads neither the header block nor the
// (possibly large) body; Headers adds the header block; Full adds both — use
// Full only if you must hash or transmit the whole message.
const LOAD: stage::Load = stage::Load::Metadata;

/// Persistent dispatcher worker: read ids from stdin until EOF. This is the
/// only entry point; there is no one-shot form.
pub async fn worker(cfg: &Config) -> anyhow::Result<()> {
    stage::run_worker(PROGRAM, cfg, LOAD, act).await
}

async fn act(ctx: StageContext<'_>) -> anyhow::Result<()> {
    let id = ctx.message.workqueue_id;
    // Read program options from this stage's own section:
    let mycfg = MyCfg::parse(&ctx.section())?;
    // Read inputs you need from state / columns (ctx.cfg is the Config):
    let dsn = DsnParams::from_state(ctx.message.state_value(), 0);

    // ... decide what to do (never println! — stdout is the worker channel) ...

    // Terminate with exactly ONE of the helpers (see below).
    ctx.advance().await
}

L’API StageContext :

  • ctx.message — la ligne chargée : workqueue_id, token, mail_from, rcpt_to, from_header, subject, headers facultatif, body facultatif, stage, status, state, settings_map, age_secs, et les helpers is_bounce() et state_value(). headers n’est renseigné que sous Load::Headers/Load::Full et body uniquement sous Load::Full ; préférez les accesseurs ctx.headers() / ctx.raw_message(), qui le signalent au lieu de vous remettre un None.

  • ctx.section() — la section [stage-<name>] de l’étape pour vos options.

  • ctx.headers() — le bloc d’en-têtes (nécessite Load::Headers ou Load::Full).

  • ctx.raw_message() — le message réassemblé (nécessite Load::Full).

  • ctx.require_next_stage() — le NEXT_STAGE configuré.

  • Setters de contenu — pour réécrire le message, n’émettez jamais de SQL : notez le changement avec set_mail_from, set_recipients, set_from_header, set_subject, set_headers, set_body, set_state (remplacer), merge_state (fusion || superficielle), clear_state (mettre NULL) ou set_auth_verdict(key, val) / set_auth_fields(...) (mettre un ou plusieurs membres state.auth.<key> ; appelez-le une seule fois par corps d’étape, car chaque appel reconstruit auth à partir de la ligne chargée). Le changement est plié dans la ligne en mémoire et écrit par le worker en même temps que votre transition terminale en un seul UPDATE. Une réécriture de contenu doit donc se terminer par un terminal d’avancement/d’échec (advance/advance_to/reroute/fail/ set_pending), jamais par un terminal à fan-out qui préserve la ligne — pause, pause_with, finish_with, split_off_recipients, fan_out ou split_to_one_per_row, qui appellent tous une fonction côté serveur ne touchant qu’à ses propres colonnes. Ces six-là n’abandonnent pas le changement en silence : ils font bail! si l’un d’eux est en attente. (finish() simple est dispensé, puisqu’il supprime la ligne.)

  • ctx.telemetry_update("<key>") — noter que votre fonctionnalité a été exercée (non bloquant, infaillible, et sans effet à moins que l’opérateur n’ait donné son consentement explicite en activant [pepsi] SHARE_TELEMETRY, ce qui n’est pas la valeur par défaut). Appelez-le là où la fonctionnalité fait réellement son travail, avec la clé stable que vous lui avez enregistrée (voir Maintenir à jour la table de stabilité des fonctionnalités ci-dessous).

24.4. Helpers terminaux (en choisir exactement un)

Le helper terminal que vous appelez est le contrat avec le dispatcher :

Helper

Effet

advance() / advance_to(s)

Déplacer vers NEXT_STAGE (ou s) et y mettre la ligne à ``pending`` ; le pool de workers de l’étape suivante la réclame. L’étape possède cette transition de bout en bout — la seule écriture de progression du dispatcher sur status est la réclamation pending→running, jamais l’inverse. (Lorsque le successeur est fusionné, aucune ligne n’est écrite à ce saut ; la chaîne ne valide qu’une fois.)

reroute(stage, state)

Déplacer vers stage en fusionnant state ; utilisé pour passer la main à une étape de rebond.

pause(state, secs)

paused + un timeout de réessai ; le dispatcher le remet en file plus tard.

pause_with(merge, secs, keep, clones)

Mettre en pause et, dans le même aller-retour, réduire la ligne à un sous-ensemble de destinataires et/ou engendrer des SideClones latéraux (un DSN de délai, des DSN de succès pour les destinataires déjà servis).

fail(state)

Terminal failed, laissé à un opérateur.

finish()

DELETE de la ligne (message consommé). finish_with(clones) supprime et engendre des clones latéraux (rebonds par destinataire, DSN de succès) en un seul appel.

set_pending()

Rendre la ligne à la même étape à l’état pending, pour qu’elle soit revendiquée et rechargée. Utilisé après une scission, afin que la passe suivante voie la ligne réduite.

advance_or_finish()

Avancer si un NEXT_STAGE existe, sinon terminer.

complete_success(dsn, diag, rcpt)

Succès de type remise : émettre un DSN positif si configuré, sinon advance_or_finish.

Trois helpers supplémentaires éclatent la ligne au lieu de la conclure : ils réduisent les destinataires de cette ligne et créent des lignes sœurs pending en un seul appel à workqueue_split, et l’appelant doit tout de même terminer cette ligne avec l’un des terminaux ci-dessus. split_off_recipients(suffix, stage, split, keep) détache un sous-ensemble d’indices sur une seule ligne sœur ; split_to_one_per_row() détache les destinataires 1.. chacun sur sa propre ligne à l’étape courante (ce qu’utilisent les relais SMTP, puisqu’ils remettent un destinataire d’enveloppe par tentative) ; et fan_out(keep, keep_state, groups) en est la forme générale, dont les FanGroups peuvent nommer des adresses entièrement nouvelles, ce qui lui permet d’exprimer des réécritures de destinataires. Le status d’un groupe est GroupStatus::Pending pour un éclatement ordinaire ; Retry { retry_secs } le crée paused à son étape (un destinataire à réessayer plus tard pendant que le reste poursuit son chemin) et Failed le crée failed. fan_out_then(keep, keep_state, groups, then) est un terminal : il éclate la ligne puis termine ou met en pause cette ligne (SplitThen::Finish / SplitThen::Pause) dans le même appel, pour une étape qui a déjà fait hors de la base de données quelque chose qu’elle ne doit pas faire deux fois – remis à un MDA, exécuté un tube .forward – et ne doit pas laisser de fenêtre dans laquelle un échec la ferait réessayer.

enqueue_new(...) se tient entièrement en dehors de la transition : il injecte un tout nouveau message annexe (une réponse automatique, une demande de paiement) à une étape nommée via workqueue_inject et renvoie son identifiant ; cette ligne-ci a toujours besoin de son propre terminal. L’injection a lieu au plus une fois par jeton tant que ce message est en file, de sorte qu’une étape réessayée peut injecter de nouveau la même réponse (même jeton) sans erreur.

24.5. Erreurs

Renvoyer Err depuis le corps de l’étape ne fait pas échouer le message. L’erreur est considérée comme une défaillance de l’hôte – un helper qui n’a pas pu démarrer, un fichier, une clé, un modèle ou une table qui n’a pas pu être lu, une instruction de base de données refusée, un service qui n’a pas répondu – et le worker met le message en pause et le réessaie, à intervalles croissants, jusqu’au MAX_LIFETIME de l’étape ; il le réachemine alors vers le BOUNCE_STAGE de l’étape ou le fait échouer. La seule exception est une erreur enveloppée dans stage::permanent(e) : un défaut du message (une entrée que vous ne savez pas analyser, une construction que vous refusez, une panique interceptée pendant son analyse), qui fait échouer le message immédiatement. Marquez celles-là, et seulement celles-là ; une défaillance de l’hôte marquée permanente annonce à l’expéditeur que son courrier n’a pu être remis parce que le serveur a passé dix mauvaises minutes, et une erreur permanente que vous avez oublié de marquer coûte des réessais et un DSN tardif. Lorsque le sort du message relève d’une décision de politique plutôt que d’une erreur, acheminez-le vous-même (reroute vers BOUNCE_STAGE avec dsn::bounce_state) afin que l’expéditeur reçoive une raison précise. stage::temporary(e) existe toujours et ne change rien, mais documente que vous avez examiné le cas.

Deux conséquences façonnent le code :

  • Vérifiez votre configuration au démarrage. Utilisez stage::run_worker_checked(PROGRAM, cfg, LOAD, check, body), où check(cfg, stage_def) analyse la section de l’étape (ainsi que toute section globale ou tout secret dont elle a besoin). Un échec fait refuser au worker de démarrer, de sorte que le dispatcher retient la file au lieu que chaque message échoue de son côté. Pour chaque message, enveloppez l’analyse de ctx.section() dans ctx.config_error(e) : l’erreur est permanente lorsqu’une surcharge par adresse a cassé la section, et réessayée sinon.

  • Ordonnez les effets de bord pour qu’un réessai soit sans danger. Un réessai exécute l’étape depuis le début. Tout ce qui a été validé avant l’erreur – une réponse injectée, une réservation prise, une clé générée, un paiement effectué – se reproduit ou est trouvé déjà pris. Placez ces effets dans l’instruction même du terminal lorsqu’il en existe une (fan_out_then, finish_with, pause_with, les validations de liste), rendez-les idempotents sur un jeton stable ou, dès qu’un effet hors de la base de données est irrévocable, cessez de renvoyer des erreurs : journalisez, et terminez la ligne avec un terminal.

rewrite_recipients(rcpt_to, state, next_stage) est un terminal de commodité : set_recipients + set_state (tel quel, sans fusion) + advance_to, de sorte qu’un rcpt_to entièrement nouveau est écrit dans l’unique validation du worker. C’est sur lui que repose pepsi-stage-aliases ; l’appelant maintient state.dsn.rcpt parallèle à la nouvelle liste (dsn::rebuild_rcpt).

24.6. Les règles auxquelles les étapes se tiennent

Pour rester cohérent avec le reste du pipeline, honorez ces invariants :

  • Un SELECT, un UPDATE. Choisissez le bon Load ; notez les changements de contenu via les setters et terminez par un seul helper terminal. Le worker valide chaque changement et la transition en un unique UPDATE construit à partir exactement des colonnes que vous avez touchées — n’émettez pas votre propre SQL et n’ajoutez pas d’allers-retours supplémentaires.

  • Préservez ``state.dsn`` (et les autres clés) — utilisez merge_state (pas set_state), donc n’écrasez pas state en entier sauf si vous frappez intentionnellement un nouveau message (seule l’étape de rebond fait cela, via clear_state).

  • Ne faites jamais rebondir un rebond. Vérifiez ctx.message.is_bounce() avant de produire une notification ; un message à expéditeur nul est abandonné, non renvoyé en rebond (RFC 5321 §6.1).

  • Honorez le ``NOTIFY`` du DSN. Si vous générez des notifications, conditionnez-les au NOTIFY du destinataire via pepsi_common::dsn (échec par défaut ; succès/délai uniquement sur demande explicite).

  • Fail-open là où un message bloqué est pire qu’un message imparfait (l’étape ARC avance sans sceller, avec un avertissement, plutôt que de bloquer) — mais seulement lorsque c’est sûr pour votre étape, et jamais en silence : une défaillance de l’hôte que vous avalez est une défaillance dont l’opérateur n’entend pas parler.

  • Soyez idempotent / sûr au redémarrage. Une étape peut être réessayée après un plantage ; concevez de sorte que ré-exécuter sur la même ligne soit sûr (le dispatcher réinitialise les lignes running orphelines en pending).

24.7. Le câbler

Construisez et installez le nouveau binaire, puis ajoutez une section d’étape et faites pointer un voisin vers elle

[stage-spamfilter]
PROGRAM = pepsi-stage-spamfilter
NEXT_STAGE = deliver
# ... your program's own options ...

[stage-init]
PROGRAM = pepsi-stage-arc
NEXT_STAGE = spamfilter      # was: deliver

Exécutez pepsi-setup ... run pour valider le graphe — la validation fait partie de run (validate_stage_pipeline), qui vérifie que [stage-init] existe, que chaque NEXT_STAGE/BOUNCE_STAGE se résout, et que chaque section s’analyse sous le validateur de son propre PROGRAM. pepsi-setup ... visualize imprime le graphe obtenu au format dot de Graphviz, et pepsi-config ... dump montre la configuration effective. (pepsi-setup check est autre chose : il compare le DNS en production aux enregistrements que run publierait.) Aucune modification d’ingress ni du dispatcher n’est nécessaire ; le message suivant traverse simplement votre nouvelle étape.

24.8. Tester une étape

Suivez les contraintes de test du projet : n’envoyez jamais de courrier réel vers des MX tiers (utilisez des domaines réservés/.invalid), et rappelez-vous que l’uid de développement ne peut pas se lier aux ports 25/53. Testez unitairement la logique pure de votre bibliothèque ; pour une vérification de bout en bout, exécutez un PostgreSQL jetable, installez le schéma avec pepsi-setup, insérez une ligne, et dirigez son identifiant vers un worker (echo <id> | pepsi-stage-<name> -c test.conf worker) — l’indicateur global -c doit venir avant la sous-commande. La CLI de Pepsi est une grammaire stricte à deux zones, de style git : PepsiArgs est aplati dans les Args parents en amont de la sous-commande et n’est délibérément pas global = true, de sorte qu’un pepsi-stage-<name> worker -c test.conf final est rejeté par clap avec unexpected argument.

24.9. Ajouter la prise en charge de la migration depuis un autre MTA

pepsi-setup peut importer la configuration d’un serveur de messagerie existant comme valeurs par défaut des réponses de l’assistant (voir Installation et pepsi-setup). Cinq serveurs sont pris en charge d’emblée — Postfix, Exim, Sendmail, qmail et Stalwart — et en ajouter un sixième représente un nouveau fichier plus une ligne dans un registre.

Un importateur implémente MtaImporter (pepsi-setup/src/import/mod.rs)

pub trait MtaImporter: Sync {
    fn id(&self) -> &'static str;                     // "postfix"
    fn label(&self) -> &'static str;                  // "Postfix"
    fn detect(&self, probe: &Probe) -> Option<Detected>;
    fn import(&self, probe: &Probe, found: &Detected) -> Imported;
}

Quatre règles font la différence entre un importateur qui aide et un qui induit en erreur :

  1. Il ne peut pas échouer. import renvoie Imported, pas Result. Chaque fichier illisible, ligne inanalysable, directive non reconnue et fonctionnalité non prise en charge est notée comme un Warning sur le modèle — avec le file:line dont elle provient — et le modèle partiellement rempli est renvoyé malgré tout. Une migration qui a compris neuf réglages sur dix vaut toujours la peine, et aucun fichier de configuration présent sur le disque de qui que ce soit ne doit pouvoir faire avorter la configuration initiale.

  2. Chaque directive est prise en compte. Consommez-la, listez-la dans la table IGNORED du module qui recense les réglages réellement sans intérêt, ou signalez-la avec Imported::unknown. Le silence sur un réglage dont l’opérateur dépend est précisément le mode de défaillance que cette fonctionnalité existe pour éviter. Pour les fonctionnalités auxquelles Pepsi n’a pas d’équivalent, utilisez Imported::unsupported et dites en une phrase ce qui les remplace (généralement une étape).

  3. Tout accès au système de fichiers passe par Probe. Il gère les limites de taille, les octets non-UTF-8 et les permissions, et — parce qu’il peut être enraciné sur un répertoire — c’est lui qui permet de tester unitairement l’importateur contre un arbre de fixtures sous pepsi-setup/tests/fixtures/<mta>/ sans aucun accès au système réel.

  4. Les tables de routage ne sont pas classifiées par l’importateur. Analysez-les en valeurs RawAlias { key, targets, origin } (import::aliases fournit des analyseurs pour les deux dialectes universels) et laissez import::aliases::convert décider de ce que Pepsi sait exprimer. C’est le seul endroit qui connaisse la grammaire des clés d’alias de Pepsi, les styles de qualification, et la façon de signaler une cible — un |command, un fichier, un :include: cassé — qui n’a pas d’équivalent.

Renseignez sur Imported ce que vous avez appris : identité, domaines, direction, smarthost (y compris les identifiants, en notant toujours password_source), remise locale, alias, identités de soumission, et toute option du registre crate::advanced via set_advanced — le code partagé transforme tout cela en valeurs par défaut de l’assistant, en fichiers de map convertis et en rapport de migration. Deux pièges : convertissez les durées avec text::pepsi_duration (l’analyseur de durées de Pepsi rejette les unités calendaires d et w, donc 5d doit devenir 120 h), et écartez les réseaux de loopback lors de l’import d’une liste de réseaux de confiance (une entrée de loopback provoque une boucle de courrier).

Enfin, ajoutez l’importateur à import::registry() et donnez-lui une ligne dans contrib/feature-registry.tsv.

24.9.1. Tester un importateur

Chaque arbre de fixtures réside dans pepsi-setup/tests/fixtures/<mta>/<case>/ et est une racine de sonde : le répertoire tient lieu de /, de sorte que pepsi-setup import <mta> --root <that directory> reproduit à la main exactement ce que les tests exécutent. Donnez-en au moins trois à un nouvel importateur : une installation réaliste, une seconde disposition prise en charge par ce serveur (une configuration éclatée, un fichier généré, un hôte purement relais), et une disposition hostile — lignes tronquées, guillemets non fermés, bruit binaire, un répertoire là où un fichier est attendu, un include illisible. Tout arbre dont le nom contient hostile est tenu de produire des avertissements.

Trois suites s’appliquent alors, et un nouvel importateur est couvert par deux d’entre elles dès que ses fixtures sont en place :

pepsi-setup/tests/import_pipeline.rs

Énumère chaque arbre de fixtures du dépôt et soumet tous les importateurs aux invariants partagés : seul l’importateur propriétaire revendique un arbre, chaque avertissement porte un sujet, une explication et une origine nommant un fichier, la map d’alias convertie se charge dans les trois styles de qualification avec converted + dropped égal au nombre d’entrées source, les réponses pré-remplies ne sont jamais vides, et le rapport rendu rend compte de chaque avertissement sans émettre de caractères de contrôle.

pepsi-setup/tests/import_<mta>.rs

La suite propre à chaque serveur : ce que cet importateur comprend, vérifié de bout en bout à travers l’API publique. tests/common/mod.rs fournit l’utilitaire Case (Case::load("postfix", "postfix/basic")) avec les assertions warned/not_warned — c’est not_warned qui prouve qu’une directive a été consommée plutôt que balayée dans le tas des UNKNOWN.

tests/cli_import.rs

Exécute le vrai binaire : l’aperçu import, le sélecteur lorsque plusieurs serveurs de messagerie sont installés, et une migration --wizard --import complète dont la configuration générée doit passer la validation propre à Pepsi — l’assistant refuse d’en écrire une qui échoue.

24.10. Intégrer un service externe de rafraîchissement d’identifiants

Certaines étapes s’authentifient auprès d’un amont avec un identifiant à courte durée de vie qui doit être renouvelé hors bande. Le cas de référence est le relais vers smarthost (pepsi-stage-relay-to-smarthost) avec AUTH = oauth : il lit un jeton porteur SASL, frais à chaque remise, depuis un TOKEN_FILE ; ce jeton d’accès OAuth expire à peu près toutes les heures. Pepsi livre pepsi-helper-token-refresh(1) pour garder le fichier à jour, mais ce n’est qu”une implémentation d’un contrat — vous pouvez insérer votre propre rafraîchisseur (un job cron qui interroge un point de terminaison de jeton, un agent de fournisseur cloud, un sidecar de gestionnaire de secrets) tant qu’il honore le contrat ci-dessous.

24.10.1. Pourquoi un service séparé, pas une étape

Le rafraîchissement n’est pas un travail par message, il a besoin du secret client du fournisseur (que l’étape ne doit jamais voir), et il devrait continuer à tourner même quand aucun courrier ne circule. C’est donc un programme autonome qui s’exécute en tant que son propre utilisateur, plus digne de confiance, et il ne fait délibérément pas partie de pepsi.target : un site peut déjà rafraîchir les jetons par d’autres moyens. Ne l’activez que lorsque vous voulez que Pepsi fasse le rafraîchissement.

24.10.2. Le contrat

Tout rafraîchisseur — celui de Pepsi ou le vôtre — doit satisfaire quatre choses :

  1. Gardez les secrets client hors de l’étape. Mettez la configuration de la source d’identifiants dans un fichier séparé et tirez-la dans pepsi.conf avec la directive taler @inline-secret@ <SECTION> <FILE> (voir pepsi.conf(5)). Rendez ce fichier lisible uniquement par l’utilisateur du rafraîchisseur. @inline-secret@ est spécial précisément ici : un lecteur qui ne peut pas ouvrir le fichier (l’étape, exécutée en tant que pepsi) saute silencieusement la section et analyse le reste de la configuration normalement, alors qu’un simple @inline@ produirait une erreur. Ainsi le secret ne parvient jamais à l’étape, et pourtant les deux programmes partagent un seul pepsi.conf.

  2. Écrivez l’identifiant là où l’étape le lit, de façon atomique. Remplacez le contenu du TOKEN_FILE par une écriture-vers-temporaire-puis-rename, de sorte que l’étape, qui peut lire à tout instant, ne voie jamais un fichier partiel. Le contenu du fichier est exactement l’identifiant que l’étape attend (pour OAuth : le jeton d’accès nu, débarrassé des espaces).

  3. Réglez correctement le groupe et le mode. Les fichiers de jeton résident dans un répertoire qui est SGID au groupe pepsi-token (par défaut /var/pepsi/tokens), de sorte qu’un nouveau fichier hérite de ce groupe ; écrivez-le en mode 0640. Le binaire d’étape consommateur est installé SGID pepsi-token, ce qui permet au worker pepsi non privilégié du dispatcher de lire le jeton — sans faire de pepsi un membre permanent du groupe. Tout secret de longue durée que le rafraîchisseur persiste pour lui-même (par exemple un jeton de rafraîchissement renouvelé) appartient à un répertoire privé (mode 0700), jamais au répertoire de jetons lisible par le groupe.

  4. Échouez en sûreté. En cas d’erreur de rafraîchissement, laissez le fichier de jeton précédent en place et réessayez avec un back-off ; ne tronquez jamais un jeton fonctionnel parce que le point de terminaison était brièvement injoignable. L’étape de relais traite déjà un jeton manquant/expiré comme un échec de remise transitoire, de sorte que le message attend simplement et réessaie une fois qu’un jeton frais arrive.

24.10.3. Remplacer le rafraîchisseur fourni

Pour substituer votre propre implémentation, gardez le service pepsi-helper-token-refresh désactivé (il est livré désactivé) et faites en sorte que votre programme écrive le même TOKEN_FILE avec les mêmes permissions. Rien ne change dans l’étape, l’ingress ou le dispatcher — l’étape ne fait jamais que lire le fichier. pepsi-setup imprime un rappel chaque fois qu’un smarthost utilise AUTH = oauth pour que vous n’oubliiez pas de câbler un rafraîchisseur.

Le même schéma se généralise à toute future étape qui a besoin d’un secret périodiquement renouvelé : donnez-lui une option *_FILE, lisez le fichier à chaque usage, mettez les identifiants émetteurs derrière @inline-secret@, et exécutez l’émetteur en tant que son propre utilisateur, écrivant via un fichier à portée de groupe, lisible par l’étape SGID.

24.10.4. Kerberos (AUTH = gssapi)

L”AUTH = gssapi du relais vers smarthost suit le même contrat de rafraîchissement externe, mais l’identifiant renouvelé est un ticket Kerberos dans un cache d’identifiants plutôt qu’un fichier de jeton, et le rafraîchisseur est un outil standard plutôt qu’un binaire Pepsi — Pepsi n’en livre donc aucun. Le contrat est :

  1. Acquérez et renouvelez le ticket hors bande. Exécutez k5start/krenew (ou un job cron kinit -k) sous un utilisateur plus digne de confiance, en vous authentifiant depuis un keytab que l’étape de relais ne lit jamais. Cela obtient et renouvelle périodiquement un ticket-granting ticket pour le principal propre de Pepsi.

  2. Écrivez le cache là où l’étape le lit. Pointez k5start vers un cache d’identifiants FILE: sous le répertoire SGID pepsi-token /var/pepsi/krb5 (de sorte que le fichier hérite du groupe), et nommez-le dans l’option KRB5CCNAME du MTA (ou réglez KRB5CCNAME dans l’environnement du service dispatcher pour le cache par défaut). Le binaire d’étape smarthost SGID — déjà SGID pepsi-token pour OAuth/mTLS — est ce qui permet au worker pepsi de le lire.

  3. Échouez en sûreté. Un ticket manquant ou expiré fait de la remise un échec transitoire, de sorte que le message attend et réessaie une fois qu’un ticket valide est dans le cache.

pepsi-setup imprime un rappel (et vérifie la lisibilité du cache FILE:) chaque fois qu’un smarthost utilise AUTH = gssapi.

24.11. Cycle de vie du schéma de base de données

Le schéma est une série de fichiers de patch numérotés sous le préfixe SQL pepsi (dans pepsi-setup/db/) :

  • pepsi-NNNN.sql — les patchs numérotés. pepsi-setup les applique dans l’ordre (pepsi-0001.sql, pepsi-0002.sql, …) jusqu’à ce que l’un d’eux manque.

  • procedures.sql — des fonctions stockées recréables (CREATE OR REPLACE), réappliquées à chaque exécution.

  • drop.sql — DROP SCHEMA pepsi CASCADE, utilisé uniquement par run --reset.

  • versioning.sql — la mécanique de suivi des patchs.

Les patchs appliqués sont consignés (sous leur nom register_patch), de sorte que les réexécutions sont idempotentes.

Le numéro de patch n’est pas incrémenté à chaque changement de schéma. Un nouveau pepsi-NNNN.sql n’est commencé que pour le premier changement de schéma effectué après une version publiée — c’est-à-dire après une étiquette Git de la forme v$MAJ.$MIN.$REV. Entre deux versions, le fichier de patch le plus récent est modifié sur place : aucun déploiement publié ne porte ce schéma pas encore publié, de sorte que ses instructions peuvent être librement complétées ou réécrites. Une fois une version étiquetée, ce fichier est figé, et le changement suivant qui touche le schéma devient pepsi-(NNNN+1).sql, dont le register_patch déclare le patch précédent comme dépendance — ainsi les bases de données déployées avec le schéma publié migrent vers l’avant de façon incrémentale (avec ALTER), tandis qu’une installation neuve applique toujours chaque patch dans l’ordre.

La première version, v0.0.0, a livré tout le schéma sous la forme de l’unique pepsi-0001.sql (CREATE seulement, sans ALTER/DROP), qui est donc figé. Le prochain changement de schéma commence pepsi-0002.sql, enregistré avec register_patch('pepsi-0002', ARRAY['pepsi-0001'], NULL) (gardez le nom de fichier et son nom register_patch synchronisés), et fait avancer une base de données v0.0.0 avec ALTER.

Le contrôle du schéma. Le script de build de pepsi-common calcule l’empreinte de chaque pepsi-NNNN.sql et de procedures.sql et l’intègre à la bibliothèque, l’installateur enregistre l’empreinte et la version de chaque fichier qu’il applique dans pepsi.schema_file, et chaque pool qu’ouvre un programme compare les deux (pepsi_common::schema). Il n’y a donc aucun numéro de version de schéma à incrémenter : modifier n’importe quel fichier SQL change ce qu’attend la compilation suivante, et un programme refuse, avec le code de sortie 78, une base de données installée à partir de quoi que ce soit d’autre (voir Mise à niveau). Deux conséquences pour le développement : après avoir modifié procedures.sql, relancez pepsi-setup schema (ou make check) avant de démarrer des programmes sur une base de données existante ; et un test qui construit sa propre base de données doit l’installer avec pepsi_common::schema::dbinit – comme le fait le harnais de test – plutôt qu’en appliquant les fichiers à la main. Les seuls pools qui sautent le contrôle sont ceux de db::pool_unchecked : l’installateur et l’applicateur de configuration.

Geler une version. Dans le commit de la version :

  • ajoutez les fichiers de patch de la version et leur SHA-256 à pepsi-setup/db/RELEASED (TAG FILE SHA256 par ligne). Un test unitaire échoue si un fichier listé change un jour, et un programme qui rencontre une base de données construite à partir d’une autre copie d’un patch listé signale un bogue au lieu de conseiller une réinitialisation ;

  • gelez le jeu de données de mise à niveau avec tests/upgrade/freeze.sh vX.Y.Z (voir tests/upgrade/README). La tâche de CI 4-upgrade met à niveau le schéma et le jeu de données de la version précédente vers l’arbre testé, vérifie que chaque ligne survit et que la sauvegarde prise avant la mise à niveau se restaure, vide l’ancienne file avec le nouveau dispatcher, et vérifie que l’ancienne version refuse le résultat.

Les lignes en file survivent à une version. Une mise à niveau ne vide pas la file, si bien que les étapes de la nouvelle version rencontrent des lignes – et leur JSON state – écrites par l’ancienne. Ne renommez jamais, ne supprimez jamais et ne changez jamais le type d’une clé de state qu’une ligne en file peut porter ; ajoutez plutôt une nouvelle clé, et faites en sorte que chaque lecteur tolère son absence. tests/upgrade/seed.sql devrait porter chaque clé qu’une étape écrit, afin que le test de mise à niveau la voie.

24.12. Maintenir à jour la table de stabilité des fonctionnalités

Chaque fonctionnalité visible par l’utilisateur est listée dans l’unique table Stabilité des fonctionnalités. Cette page est générée par contrib/update-feature-stability.sh, qui fusionne deux sources :

  • le registre maintenu à la main contrib/feature-registry.tsv — une ligne par fonctionnalité, avec sa key (l’identifiant de télémétrie stable), son nom lisible par un humain, la RFC qui la régit, sa couverture de test automatisé (U/I/I/U/-), son statut de test manuel (yes/no) et le fait qu’elle soit instrumentée pour la télémétrie (yes/no) ; et

  • un GET /telemetry/report de pepsi-telemetry — les comptes de déploiement et d’usage par fonctionnalité, joints au registre sur key.

Une fonctionnalité dont la colonne telemetry vaut no ne peut pas être comptée de façon significative. Une telle ligne est rendue avec un (*) après son nom et un — dans ses cases Déploiements, Usages et Stabilité ; elle n’a aucun appel à telemetry_update. Il y a quatre raisons pour qu’une ligne soit dans cet état, et la raison doit être consignée à côté de la clé dans KNOWN_UNINSTRUMENTED (tests/feature_registry.rs) — un test vérifie que la liste et cette colonne s’accordent, afin que l’on puisse distinguer « pas encore mesuré » de « ne sera jamais mesuré » :

  • Une capacité de protocole passive dont le client ne signale jamais l’usage (ENHANCEDSTATUSCODES). Notez que PIPELINING n’en fait pas partie : un client qui envoie une commande sans attendre la réponse est directement observable, et l’ingress le rapporte une fois par session.

  • Une facilité universelle dont le compte mesurerait des démarrages de processus plutôt que de l’usage — la journalisation structurée, le canal de télémétrie lui-même, pepsi-setup (chaque déploiement l’exécute, si bien que le compte ferait double emploi avec le nombre de déploiements).

  • Une propriété d’une configuration ou d’un schéma plutôt qu’un événement : le droit pepsi-crypto sur la colonne des clés privées, dont l”« usage » est l”absence d’erreur de permission.

  • Un processus de courte durée : une CLI d’opérateur, ou un pepsi-helper-* privilégié. C’est une limite dure de l’API productrice.

telemetry_update remet le nom de la fonctionnalité à une tâche d’arrière-plan et rend la main ; la tâche a ensuite besoin d’un tour du runtime Tokio pour se connecter à la socket locale pepsi-telemetry-client et écrire. Un processus qui sort de son block_on immédiatement après avoir rapporté est détruit avant que ce tour n’ait lieu, et l’événement est perdu — mesuré à 0 remise sur 200 exécutions, contre 30 sur 30 lorsque le processus reste une milliseconde de plus dans le runtime (pepsi-common/tests/telemetry_short_lived.rs le fige). Le seuil n’est donc pas « le processus doit vivre un certain temps » mais « le runtime doit tourner une fois de plus ». Un démon de longue durée ou un worker d’étape n’est pas concerné ; une CLI ne peut pas rapporter ce qu’elle vient précisément de finir de faire, le seul moment intéressant qu’elle ait. Câbler les outils d’opérateur exige donc d’abord un flush() borné et attendu dans l’API productrice — et une notion d’échelle par ligne, car les paliers de stabilité sont étalonnés sur des messages (1 000 usages pour used, 100 000 pour stable) et une commande qu’un humain lance une douzaine de fois par an resterait clouée à experimental pour toujours par un compteur qui fonctionnerait parfaitement.

Les programmes qui installent effectivement le puits, et peuvent donc rapporter, sont pepsi-ingress, les workers d’étape (via run_worker), pepsi-dispatch, pepsi-httpd, pepsi-keydisc, pepsi-failure-bouncer dans son mode service, l’applicateur pepsi-setup apply, et l’assistant pepsi-setup une fois qu’il a écrit une configuration dont il peut relire la réponse SHARE_TELEMETRY. Rapporter quoi que ce soit depuis le questionnaire avant que l’opérateur n’ait répondu soumettrait l’activité d’un déploiement qui n’a jamais consenti, ce que le consentement explicite existe précisément pour empêcher.

Chaque fois que vous modifiez le code ou les tests, gardez la table honnête :

  1. Ajouter une fonctionnalité. Ajoutez une ligne à contrib/feature-registry.tsv. Sauf si la fonctionnalité est véritablement non instrumentable (mettez telemetry = no — réservez ceci aux cas ci-dessus, et ajoutez la clé à KNOWN_UNINSTRUMENTED dans tests/feature_registry.rs avec la raison), mettez telemetry = yes et appelez telemetry_update("<key>") à un bon endroit dans le code — quelque part où la fonctionnalité s’exécute vraiment, de sorte que le compte reflète l’usage réel (et que l’instantané « activé » reflète les déploiements qui l’utilisent réellement). Dans une étape, utilisez ctx.telemetry_update("<key>") au point où l’étape fait son travail caractéristique ; ailleurs, appelez directement pepsi_common::telemetry::telemetry_update("<key>") (l’ingress fait cela pour starttls, 8bitmime, les vérifications d’authentification entrante spf / dkim-verify / dmarc / iprev / auth-results, et le client de relais pour dane, tlsrpt et tls-identity). La chaîne key dans le code et dans le registre doit correspondre — c’est ainsi que les comptes de télémétrie se rattachent à la ligne, et l’avertissement stderr du générateur (ci-dessous) attrape toute dérive.

  2. Écrire un test. Mettez à jour la colonne test de cette fonctionnalité : U si vous avez ajouté un cargo test unitaire / en-processus, I pour un script tests/*.sh de pipeline en service ou un test de bout en bout pepsi-test-stages, I/U pour les deux.

  3. Tester manuellement. Mettez la colonne manual de la fonctionnalité à yes.

  4. Rafraîchir les comptes. Exécutez contrib/update-feature-stability.sh (il récupère TELEMETRY_URL ou lit un TELEMETRY_REPORT_FILE ; sans ni l’un ni l’autre, chaque compte est 0 et chaque fonctionnalité est experimental) et committez le docs/manual/feature-stability.rst régénéré. Le script avertit sur stderr au sujet de toute fonctionnalité de télémétrie sans ligne de registre, de sorte qu’une key égarée ou renommée est attrapée.

Ne modifiez pas docs/manual/feature-stability.rst à la main — il est écrasé à chaque régénération.

cargo test --test feature_registry impose tout ce qui précède sans nécessiter de déploiement de télémétrie : chaque clé rapportée a une ligne, chaque ligne est atteignable depuis les sources, aucune clé n’est dans KNOWN_UNINSTRUMENTED alors que le code la rapporte, et la liste et la colonne telemetry disent la même chose.