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,failou 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 :
Crate de bibliothèque
pepsi-stage-<name>avec :constants.rs— unCONFIG_SOURCEet une chaînePROGRAM(le nom de binaire que les opérateurs mettent dansPROGRAM =).config.rs(facultatif) — une structure analysée depuis laSectionde l’étape pour toute option spécifique au programme.lib.rs— le corps d’étapeasync fn body(ctx: StageContext<'_>) -> anyhow::Result<()>, une constanteLOAD, et l’unique point d’entrée finworker(run_worker).
Module de programme
src/programs/stage_<name>.rsdans le paquet racinepepsi: lesArgsclap (#[clap(flatten)] common: PepsiArgssuivi de#[command(subcommand)] cmd: Command, dont la seule variante estWorker) et unpub fn main()qui appellepepsi_main(CONFIG_SOURCE, args.common, …). Enregistrez le module danssrc/programs/mod.rs.Binaire
src/bin/pepsi-stage-<name>.rs— un mincefn main() { pepsi::programs::stage_<name>::main(); }avec une entrée[[bin]]portantrequired-features = ["multibin"]dans leCargo.tomlde l’espace de travail. C’est la disposition de développement ; le build de release plie au contraire le programme dans le binaire multi-appelpepsi, ajoutez donc aussi une branche àrun_appletet un nom àAPPLETSdanssrc/bin/pepsi.rs.Enregistrez la crate de bibliothèque dans le
Cargo.tomlde l’espace de travail et ajoutez le nom du programme àFOLDED_BINARIESdans leMakefile(ou àSTANDALONE_BINARIESs’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 parexec).(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 macrofusion_entry!danssrc/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 leFUSIONde sa section. La fusion exige que la crate exporte une constante publiqueLOADet une fonction de corps publique, et elle est refusée à l’exécution à moins que leLoaddu 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,headersfacultatif,bodyfacultatif,stage,status,state,settings_map,age_secs, et les helpersis_bounce()etstate_value().headersn’est renseigné que sousLoad::Headers/Load::Fulletbodyuniquement sousLoad::Full; préférez les accesseursctx.headers()/ctx.raw_message(), qui le signalent au lieu de vous remettre unNone.ctx.section()— la section[stage-<name>]de l’étape pour vos options.ctx.headers()— le bloc d’en-têtes (nécessiteLoad::HeadersouLoad::Full).ctx.raw_message()— le message réassemblé (nécessiteLoad::Full).ctx.require_next_stage()— leNEXT_STAGEconfiguré.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(mettreNULL) ouset_auth_verdict(key, val)/set_auth_fields(...)(mettre un ou plusieurs membresstate.auth.<key>; appelez-le une seule fois par corps d’étape, car chaque appel reconstruitauthà 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 seulUPDATE. 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_outousplit_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 fontbail!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 |
|---|---|
|
Déplacer vers |
|
Déplacer vers |
|
|
|
Mettre en pause et, dans le même aller-retour, réduire la ligne à un sous-ensemble de destinataires et/ou engendrer des |
|
Terminal |
|
|
|
Rendre la ligne à la même étape à l’état |
|
Avancer si un |
|
Succès de type remise : émettre un DSN positif si configuré, sinon |
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 dectx.section()dansctx.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 uniqueUPDATEconstruit à 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(passet_state), donc n’écrasez passtateen entier sauf si vous frappez intentionnellement un nouveau message (seule l’étape de rebond fait cela, viaclear_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
NOTIFYdu destinataire viapepsi_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
runningorphelines enpending).
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 :
Il ne peut pas échouer.
importrenvoieImported, pasResult. Chaque fichier illisible, ligne inanalysable, directive non reconnue et fonctionnalité non prise en charge est notée comme unWarningsur le modèle — avec lefile:linedont 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.Chaque directive est prise en compte. Consommez-la, listez-la dans la table
IGNOREDdu module qui recense les réglages réellement sans intérêt, ou signalez-la avecImported::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, utilisezImported::unsupportedet dites en une phrase ce qui les remplace (généralement une étape).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 souspepsi-setup/tests/fixtures/<mta>/sans aucun accès au système réel.Les tables de routage ne sont pas classifiées par l’importateur. Analysez-les en valeurs
RawAlias { key, targets, origin }(import::aliasesfournit des analyseurs pour les deux dialectes universels) et laissezimport::aliases::convertdé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>.rsLa suite propre à chaque serveur : ce que cet importateur comprend, vérifié de bout en bout à travers l’API publique.
tests/common/mod.rsfournit l’utilitaireCase(Case::load("postfix", "postfix/basic")) avec les assertionswarned/not_warned— c’estnot_warnedqui prouve qu’une directive a été consommée plutôt que balayée dans le tas des UNKNOWN.tests/cli_import.rsExécute le vrai binaire : l’aperçu
import, le sélecteur lorsque plusieurs serveurs de messagerie sont installés, et une migration--wizard --importcomplè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 :
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.confavec 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 quepepsi) 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 seulpepsi.conf.Écrivez l’identifiant là où l’étape le lit, de façon atomique. Remplacez le contenu du
TOKEN_FILEpar 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).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 mode0640. Le binaire d’étape consommateur est installé SGIDpepsi-token, ce qui permet au workerpepsinon privilégié du dispatcher de lire le jeton — sans faire depepsiun 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é (mode0700), jamais au répertoire de jetons lisible par le groupe.É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 :
Acquérez et renouvelez le ticket hors bande. Exécutez
k5start/krenew(ou un job cronkinit -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.Écrivez le cache là où l’étape le lit. Pointez
k5startvers un cache d’identifiantsFILE:sous le répertoire SGIDpepsi-token/var/pepsi/krb5(de sorte que le fichier hérite du groupe), et nommez-le dans l’optionKRB5CCNAMEdu MTA (ou réglezKRB5CCNAMEdans l’environnement du service dispatcher pour le cache par défaut). Le binaire d’étape smarthost SGID — déjà SGIDpepsi-tokenpour OAuth/mTLS — est ce qui permet au workerpepside le lire.É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-setuples 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 parrun --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 SHA256par 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(voirtests/upgrade/README). La tâche de CI4-upgrademet à 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 sakey(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) ; etun
GET /telemetry/reportdepepsi-telemetry— les comptes de déploiement et d’usage par fonctionnalité, joints au registre surkey.
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 quePIPELININGn’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-cryptosur 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 :
Ajouter une fonctionnalité. Ajoutez une ligne à
contrib/feature-registry.tsv. Sauf si la fonctionnalité est véritablement non instrumentable (metteztelemetry=no— réservez ceci aux cas ci-dessus, et ajoutez la clé àKNOWN_UNINSTRUMENTEDdanstests/feature_registry.rsavec la raison), metteztelemetry=yeset appeleztelemetry_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, utilisezctx.telemetry_update("<key>")au point où l’étape fait son travail caractéristique ; ailleurs, appelez directementpepsi_common::telemetry::telemetry_update("<key>")(l’ingress fait cela pourstarttls,8bitmime, les vérifications d’authentification entrantespf/dkim-verify/dmarc/iprev/auth-results, et le client de relais pourdane,tlsrptettls-identity). La chaînekeydans 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’avertissementstderrdu générateur (ci-dessous) attrape toute dérive.Écrire un test. Mettez à jour la colonne
testde cette fonctionnalité :Usi vous avez ajouté uncargo testunitaire / en-processus,Ipour un scripttests/*.shde pipeline en service ou un test de bout en boutpepsi-test-stages,I/Upour les deux.Tester manuellement. Mettez la colonne
manualde la fonctionnalité àyes.Rafraîchir les comptes. Exécutez
contrib/update-feature-stability.sh(il récupèreTELEMETRY_URLou lit unTELEMETRY_REPORT_FILE; sans ni l’un ni l’autre, chaque compte est0et chaque fonctionnalité estexperimental) et committez ledocs/manual/feature-stability.rstrégénéré. Le script avertit surstderrau sujet de toute fonctionnalité de télémétrie sans ligne de registre, de sorte qu’unekeyé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.