24. Die Pipeline erweitern¶
Weil die Pipeline ein Zustandsautomat über einer einzigen Tabelle ist, berührt das Hinzufügen eines Verarbeitungsschritts weder Ingress noch den Dispatcher noch irgendeine andere Stage: Sie schreiben ein neues Stage-Programm und fügen seinen [stage-<name>]-Abschnitt in den Graphen ein.
Ein Stage-Programm ist ein persistenter Worker, den der Dispatcher als PROGRAM [-c FILE] worker startet; es liest eine workqueue_id pro Zeile der Standardeingabe und schreibt pro Nachricht eine Statuszeile auf die Standardausgabe (0 bei Erfolg). Für jede ID muss es: den Datensatz laden, seine Arbeit erledigen und genau einen terminalen Helper aufrufen. worker ist der einzige Laufmodus — es gibt keine positionale PROGRAM <workqueue_id>-Form; um eine einzelne Nachricht von Hand laufen zu lassen, leiten Sie ihre ID an einen Worker weiter (siehe Eine Stage testen). Alles Gemeinsame — die stdin/stdout-Worker-Schleife, das einmalige Öffnen des Pools, das Laden des Datensatzes, das Verifizieren des Abschnitts — wird von pepsi_common::stage (run_worker) bereitgestellt. Die Standardausgabe ist der Worker-Protokollkanal, daher darf ein Stage-Rumpf niemals auf sie schreiben (loggen Sie auf die Standardfehlerausgabe).
24.1. Wann eine Stage das richtige Werkzeug ist¶
Schreiben Sie eine Stage, wenn Sie eine Nachricht in Bearbeitung inspizieren oder umformen oder eine Routing-Entscheidung treffen wollen, z. B.:
ein Inhaltsfilter (Spam/Virus), der
pauset,failt oder weiterschaltet;ein Header-Umschreiber oder ein Archivierungs-/Audit-Hook;
eine alternative Zustellmethode (ein neuer Transport);
eine Integration, die pro Nachricht einen anderen Dienst aufruft.
Wenn Sie nur eine andere Verdrahtung bestehender Stages benötigen, brauchen Sie überhaupt keinen Code — nur neue [stage-*]-Abschnitte.
24.2. Anatomie einer Stage-Crate¶
Orientieren Sie sich an den bestehenden Stages (pepsi-stage-srs ist die kleinste). Eine Stage ist eine Bibliotheks-Crate plus ein dünnes Binärprogramm in der Workspace-Wurzel:
Bibliotheks-Crate
pepsi-stage-<name>mit:constants.rs— einCONFIG_SOURCEund einePROGRAM-Zeichenkette (der Binärname, den Operatoren inPROGRAM =eintragen).config.rs(optional) — eine Struktur, die aus demSectionder Stage für etwaige programmspezifische Optionen geparst wird.lib.rs— der Stage-Rumpfasync fn body(ctx: StageContext<'_>) -> anyhow::Result<()>, eineLOAD-Konstante und der einzige dünne Einstiegspunktworker(run_worker).
Programm-Modul
src/programs/stage_<name>.rsim Wurzelpaketpepsi: die clap-Args(#[clap(flatten)] common: PepsiArgs, gefolgt von#[command(subcommand)] cmd: Command, dessen einzige VarianteWorkerist) und einpub fn main(), daspepsi_main(CONFIG_SOURCE, args.common, …)aufruft. Registrieren Sie das Modul insrc/programs/mod.rs.Binärprogramm
src/bin/pepsi-stage-<name>.rs— ein dünnesfn main() { pepsi::programs::stage_<name>::main(); }mit einem[[bin]]-Eintrag samtrequired-features = ["multibin"]in der Workspace-Cargo.toml. Das ist das Entwicklungs-Layout; der Release-Build faltet das Programm stattdessen in das Multi-Call-Binärprogrammpepsiein, fügen Sie also auch einen Zweig zurun_appletund einen Namen zuAPPLETSinsrc/bin/pepsi.rshinzu.Registrieren Sie die Bibliotheks-Crate in der Workspace-
Cargo.tomlund fügen Sie den Programmnamen zuFOLDED_BINARIESimMakefilehinzu (oder zuSTANDALONE_BINARIES, wenn es ein eigenes setuid-/setgid-Bit tragen muss, was ein gemeinsames Binärprogramm nicht kann, oder wenn es separat ausgeliefert oder perexecgestartet wird).(Optional, nur für eingefaltete Stages.) Ist die Stage günstig genug, um sie im Worker-Prozess eines Vorgängers auszuführen, registrieren Sie sie in der Fusions-Registry: ein
registry.insert(...)mit dem Makrofusion_entry!insrc/fusion.rs, desseninstall()das vereinheitlichte Binärprogramm einmal beim Start aufruft. Die Registry ist die Menge der Stages, in die eine andere Stage fusioniert werden darf; eine dort nicht aufgeführte Stage fusioniert nie, ganz gleich was dasFUSIONihres Abschnitts sagt. Fusion verlangt, dass die Crate eine öffentliche KonstanteLOADund eine öffentliche Rumpffunktion exportiert, und sie wird zur Laufzeit verweigert, sofern dasLoaddes Nachfolgers nicht höchstens so groß ist wie das, was der Vorgänger bereits geladen hat.
24.3. Der Rumpf¶
Ein Stage-Rumpf ist einheitlich. Der Einstiegspunkt benennt lediglich den Rumpf und wie viel der Nachricht zu laden ist; der Rumpf handelt und terminiert:
// 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
}
Die StageContext-API:
ctx.message— der geladene Datensatz:workqueue_id,token,mail_from,rcpt_to,from_header,subject, optionalheaders, optionalbody,stage,status,state,settings_map,age_secssowie die Helperis_bounce()undstate_value().headerswird nur unterLoad::Headers/Load::Fullgefüllt undbodynur unterLoad::Full; bevorzugen Sie die Zugriffsfunktionenctx.headers()/ctx.raw_message(), die das auch sagen, statt Ihnen einNonezu reichen.ctx.section()— der[stage-<name>]-Abschnitt der Stage für Ihre Optionen.ctx.headers()— der Header-Block (erfordertLoad::HeadersoderLoad::Full).ctx.raw_message()— die wieder zusammengesetzte Nachricht (erfordertLoad::Full).ctx.require_next_stage()— das konfigurierteNEXT_STAGE.Inhalts-Setter — um die Nachricht zu überschreiben, setzen Sie niemals SQL ab: Zeichnen Sie die Änderung mit
set_mail_from,set_recipients,set_from_header,set_subject,set_headers,set_body,set_state(ersetzen),merge_state(flaches||-Zusammenführen),clear_state(aufNULLsetzen) oderset_auth_verdict(key, val)/set_auth_fields(...)(setzen ein oder mehrerestate.auth.<key>-Mitglieder; rufen Sie es einmal pro Body auf, da jeder Aufrufauthaus dem geladenen Datensatz neu aufbaut) auf. Die Änderung wird in den In-Memory-Datensatz eingefaltet und vom Worker zusammen mit Ihrer terminalen Transition in einemUPDATEgeschrieben. Eine Inhaltsumschreibung muss daher in einem weiterschaltenden/fehlschlagenden Terminal enden (advance/advance_to/reroute/fail/set_pending), niemals in einem datensatzerhaltenden Fan-out-Terminal —pause,pause_with,finish_with,split_off_recipients,fan_outodersplit_to_one_per_row, die allesamt eine serverseitige Funktion aufrufen, die nur ihre eigenen Spalten anfasst. Diese sechs verwerfen die Änderung nicht stillschweigend: Sie brechen mitbail!ab, wenn eine aussteht. (Das schlichtefinish()ist ausgenommen, da es den Datensatz löscht.)ctx.telemetry_update("<key>")— hält fest, dass Ihre Funktion genutzt wurde (nicht-blockierend, unfehlbar, und ohne Wirkung, solange der Operator nicht durch Einschalten von[pepsi] SHARE_TELEMETRYzugestimmt hat, was nicht der Standardwert ist). Rufen Sie es dort auf, wo die Funktion tatsächlich ihre Arbeit verrichtet, mit dem stabilen Schlüssel, den Sie dafür registriert haben (siehe Die Funktionsstabilitätstabelle aktuell halten unten).
24.4. Terminale Helper (wählen Sie genau einen)¶
Der terminale Helper, den Sie aufrufen, ist der Vertrag mit dem Dispatcher:
Helper |
Wirkung |
|---|---|
|
Verschiebt zu |
|
Verschiebt zu |
|
|
|
Pausiert und reduziert im selben Round-Trip den Datensatz auf eine Empfänger-Teilmenge und/oder erzeugt seitliche |
|
Terminal |
|
|
|
Gibt den Datensatz bei derselben Stage als |
|
Schaltet weiter, falls ein |
|
Erfolg im Zustellstil: gibt eine positive DSN aus, wenn konfiguriert, sonst |
Drei weitere Helfer fächern den Datensatz auf, statt ihn abzuschließen: Sie reduzieren die Empfänger dieses Datensatzes und erzeugen in einem einzigen workqueue_split-Aufruf pending-Geschwisterdatensätze, und der Aufrufer muss diesen Datensatz weiterhin mit einem der obigen Terminale abschließen. split_off_recipients(suffix, stage, split, keep) zieht eine Index-Teilmenge auf ein einzelnes Geschwister ab; split_to_one_per_row() zieht die Empfänger 1.. jeweils auf einen eigenen Datensatz bei der aktuellen Stage ab (was die SMTP-Relays verwenden, da sie je Versuch einen Umschlagempfänger zustellen); und fan_out(keep, keep_state, groups) ist die allgemeine Form, deren FanGroups brandneue Adressen benennen dürfen, sodass sie Empfänger-Umschreibungen ausdrückt. Der status einer Gruppe ist GroupStatus::Pending für ein gewöhnliches Auffächern; Retry { retry_secs } legt sie paused in ihrer Stage an (ein Empfänger, der später erneut versucht wird, während der Rest weiterläuft), und Failed legt sie failed an. fan_out_then(keep, keep_state, groups, then) ist ein Terminal: Es fächert auf und schließt diesen Datensatz dann im selben Aufruf ab oder pausiert ihn (SplitThen::Finish / SplitThen::Pause), für eine Stage, die bereits etwas außerhalb der Datenbank getan hat, das sie nicht zweimal tun darf – an einen MDA zugestellt, eine .forward-Pipe ausgeführt –, und die kein Zeitfenster offenlassen darf, in dem ein Fehler dazu führen würde, dass sie erneut versucht wird.
enqueue_new(...) steht ganz außerhalb der Transition: Es injiziert eine brandneue Neben-Nachricht (eine automatische Antwort, eine Zahlungsaufforderung) an einer benannten Stage über workqueue_inject und gibt deren ID zurück; dieser Datensatz braucht weiterhin sein eigenes Terminal. Die Injektion erfolgt höchstens einmal je Token, solange diese Nachricht in der Warteschlange steht, sodass eine erneut versuchte Stage dieselbe Antwort (mit demselben Token) ohne Fehler noch einmal injizieren darf.
24.5. Fehler¶
Gibt der Stage-Rumpf Err zurück, lässt das die Nachricht nicht fehlschlagen. Der Fehler gilt als Fehler des Hosts – ein Helfer, der nicht starten konnte, eine Datei, ein Schlüssel, eine Vorlage oder eine Map, die nicht gelesen werden konnte, eine abgelehnte Datenbankanweisung, ein Dienst, der nicht antwortete –, und der Worker pausiert die Nachricht und versucht sie mit Back-off erneut, bis zur MAX_LIFETIME der Stage; danach leitet er sie an die BOUNCE_STAGE der Stage um oder lässt sie fehlschlagen. Die einzige Ausnahme ist ein in stage::permanent(e) verpackter Fehler: ein Defekt der Nachricht (eine Eingabe, die Sie nicht parsen können, ein Konstrukt, das Sie ablehnen, eine beim Parsen abgefangene Panic), der die Nachricht sofort fehlschlagen lässt. Markieren Sie diese, und nur diese; ein als permanent markierter Host-Fehler teilt dem Absender mit, seine Mail sei unzustellbar, weil der Server schlechte zehn Minuten hatte, und ein permanenter Fehler, den Sie zu markieren vergessen haben, kostet Wiederholungen und eine verspätete DSN. Ist das Schicksal der Nachricht eine Richtlinienentscheidung statt eines Fehlers, leiten Sie sie selbst um (reroute zur BOUNCE_STAGE mit dsn::bounce_state), damit der Absender einen konkreten Grund erhält. stage::temporary(e) existiert weiterhin und ändert nichts, dokumentiert aber, dass Sie den Fall bedacht haben.
Zwei Konsequenzen prägen den Code:
Prüfen Sie Ihre Konfiguration beim Start. Verwenden Sie
stage::run_worker_checked(PROGRAM, cfg, LOAD, check, body), wobeicheck(cfg, stage_def)den Abschnitt der Stage parst (und jeden globalen Abschnitt oder jedes Geheimnis, das sie braucht). Ein Fehlschlag lässt den Worker den Start verweigern, sodass der Dispatcher die Warteschlange zurückhält, statt jede Nachricht einzeln fehlschlagen zu lassen. Verpacken Sie pro Nachricht das Parsen vonctx.section()inctx.config_error(e): Der Fehler ist permanent, wenn eine adressbezogene Überschreibung den Abschnitt kaputt gemacht hat, und wird andernfalls erneut versucht.Ordnen Sie Seiteneffekte so an, dass eine Wiederholung harmlos ist. Eine Wiederholung führt die Stage von vorn aus. Alles, was vor dem Fehler committet wurde – eine injizierte Antwort, ein genommener Claim, ein erzeugter Schlüssel, eine geleistete Zahlung –, geschieht erneut oder wird als bereits vergeben vorgefunden. Legen Sie solche Effekte, wo es sie gibt, in die eigene Anweisung des Terminals (
fan_out_then,finish_with,pause_with, die Listen-Commits), machen Sie sie über ein stabiles Token idempotent, oder hören Sie auf, Fehler zurückzugeben, sobald ein Effekt außerhalb der Datenbank unwiderruflich ist: protokollieren Sie, und beenden Sie den Datensatz mit einem Terminal.
rewrite_recipients(rcpt_to, state, next_stage) ist ein Komfort-Terminal: set_recipients + set_state (wörtlich, nicht zusammengeführt) + advance_to, sodass ein völlig neues rcpt_to im einzigen Commit des Workers geschrieben wird. Darauf baut pepsi-stage-aliases auf; der Aufrufer hält state.dsn.rcpt parallel zur neuen Liste (dsn::rebuild_rcpt).
24.6. Regeln, nach denen die Stages leben¶
Um mit dem Rest der Pipeline konsistent zu bleiben, beachten Sie diese Invarianten:
Ein SELECT, ein UPDATE. Wählen Sie das richtige
Load; zeichnen Sie Inhaltsänderungen über die Setter auf und schließen Sie mit einem terminalen Helper ab. Der Worker schreibt jede Änderung und die Transition in einem einzigenUPDATEfest, aufgebaut aus genau den Spalten, die Sie berührt haben — setzen Sie kein eigenes SQL ab und fügen Sie keine zusätzlichen Round-Trips hinzu.Bewahren Sie ``state.dsn`` (und andere Schlüssel) — verwenden Sie
merge_state(nichtset_state), überschreiben Siestatealso nicht komplett, es sei denn, Sie prägen absichtlich eine neue Nachricht (nur die Bounce-Stage tut das, überclear_state).Bouncen Sie niemals einen Bounce. Prüfen Sie
ctx.message.is_bounce(), bevor Sie eine Benachrichtigung erzeugen; eine Null-Absender-Nachricht wird verworfen, nicht erneut gebounct (RFC 5321 §6.1).Beachten Sie das DSN-``NOTIFY``. Wenn Sie Benachrichtigungen erzeugen, machen Sie sie vom
NOTIFYdes Empfängers überpepsi_common::dsnabhängig (Failure standardmäßig; Success/Delay nur auf ausdrückliche Anforderung).Fail-open dort, wo eine festhängende Nachricht schlimmer ist als eine unvollkommene (die ARC-Stage schaltet mit einer Warnung unversiegelt weiter, statt zu blockieren) — aber nur, wenn das für Ihren Schritt sicher ist, und nie stillschweigend: Ein Host-Fehler, den Sie verschlucken, ist einer, von dem der Betreiber nichts erfährt.
Seien Sie idempotent / neustartsicher. Eine Stage kann nach einem Absturz wiederholt werden; gestalten Sie sie so, dass ein erneuter Lauf auf demselben Datensatz sicher ist (der Dispatcher setzt verwaiste
running-Datensätze aufpendingzurück).
24.7. Es verdrahten¶
Bauen und installieren Sie das neue Binärprogramm, fügen Sie dann einen Stage-Abschnitt hinzu und richten Sie einen Nachbarn darauf:
[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
Führen Sie pepsi-setup ... run aus, um den Graphen zu validieren — die Validierung ist Teil von run (validate_stage_pipeline), das prüft, dass [stage-init] existiert, dass jedes NEXT_STAGE/BOUNCE_STAGE auflösbar ist und dass jeder Abschnitt unter dem Validator seines eigenen PROGRAM parst. pepsi-setup ... visualize gibt den entstehenden Graphen als Graphviz-dot aus, und pepsi-config ... dump zeigt die effektive Konfiguration. (pepsi-setup check ist etwas anderes: Es vergleicht das live-DNS mit den Einträgen, die run veröffentlichen würde.) Es ist keine Änderung an Ingress oder Dispatcher nötig; die nächste Nachricht fließt einfach durch Ihre neue Stage.
24.8. Eine Stage testen¶
Befolgen Sie die Testbeschränkungen des Projekts: Senden Sie niemals echte Mail an Drittanbieter-MX (verwenden Sie reservierte/.invalid-Domains), und denken Sie daran, dass die Entwickler-uid die Ports 25/53 nicht binden kann. Testen Sie die reine Logik in Ihrer Bibliothek per Unit-Test; für eine Ende-zu-Ende-Prüfung betreiben Sie ein Wegwerf-PostgreSQL, installieren das Schema mit pepsi-setup, fügen einen Datensatz ein und leiten dessen ID an einen Worker weiter (echo <id> | pepsi-stage-<name> -c test.conf worker) — das globale -c-Flag muss vor dem Unterbefehl stehen. Das Pepsi-CLI ist eine strikte zweizonige Grammatik im Git-Stil: PepsiArgs wird vor dem Unterbefehl in die übergeordneten Args eingebettet und ist bewusst nicht global = true, sodass ein nachgestelltes pepsi-stage-<name> worker -c test.conf von clap mit unexpected argument abgelehnt wird.
24.9. Unterstützung für die Migration von einem anderen MTA hinzufügen¶
pepsi-setup kann die Konfiguration eines vorhandenen Mailservers als Standardwerte für die Antworten des Assistenten importieren (siehe Installation und pepsi-setup). Fünf Server werden von Haus aus unterstützt — Postfix, Exim, Sendmail, qmail und Stalwart —, und einen sechsten hinzuzufügen bedeutet eine neue Datei plus eine Zeile in einem Register.
Ein Importer implementiert 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;
}
Vier Regeln machen den Unterschied zwischen einem Importer, der hilft, und einem, der in die Irre führt:
Er kann nicht fehlschlagen.
importliefertImportedzurück, nichtResult. Jede nicht lesbare Datei, jede nicht parsebare Zeile, jede nicht erkannte Direktive und jede nicht unterstützte Funktion wird alsWarningam Modell festgehalten — mitsamt derfile:line, aus der sie stammt — und das teilweise gefüllte Modell wird trotzdem zurückgegeben. Eine Migration, die neun von zehn Einstellungen verstanden hat, ist immer noch etwas wert, und keine Konfigurationsdatei auf irgendjemandes Platte darf das Setup abbrechen können.Jede Direktive wird verbucht. Verarbeiten Sie sie, führen Sie sie in der
IGNORED-Tabelle des Moduls für wirklich belanglose Stellschrauben auf, oder melden Sie sie mitImported::unknown. Schweigen über eine Einstellung, auf die sich der Operator verlässt, ist genau der Fehlerfall, zu dessen Verhinderung es diese Funktion gibt. Für Funktionen, für die Pepsi keine Entsprechung hat, verwenden SieImported::unsupportedund sagen Sie in einem Satz, was an ihre Stelle tritt (meist eine Stage).Aller Dateisystemzugriff läuft über
Probe. Es kümmert sich um Größenbeschränkungen, Nicht-UTF-8-Bytes und Berechtigungen und ist — weil es an einem Verzeichnis verwurzelt werden kann — dasjenige, was es erlaubt, den Importer gegen einen Fixture-Baum unterpepsi-setup/tests/fixtures/<mta>/als Unit zu testen, ohne Zugriff auf das echte System.Routing-Tabellen werden nicht vom Importer klassifiziert. Parsen Sie sie zu
RawAlias { key, targets, origin }-Werten (import::aliaseshat Parser für die beiden universellen Dialekte) und lassen Sieimport::aliases::convertentscheiden, was Pepsi ausdrücken kann. Es ist die einzige Stelle, die Pepsis Alias-Schlüssel-Grammatik, die Qualifizierungsstile und die Art kennt, ein Ziel zu melden — ein|command, eine Datei, ein kaputtes:include:—, für das es keine Entsprechung gibt.
Tragen Sie in Imported ein, was Sie erfahren haben: Identität, Domains, Richtung, Smarthost (einschließlich Zugangsdaten, wobei stets password_source festgehalten wird), lokale Zustellung, Aliase, Submission-Identitäten und jede Option aus dem crate::advanced-Register über set_advanced — der gemeinsame Code macht daraus die Standardwerte des Assistenten, die konvertierten Map-Dateien und den Migrationsbericht. Zwei Fallen: Konvertieren Sie Dauern mit text::pepsi_duration (Pepsis Dauer-Parser lehnt die Kalendereinheiten d und w ab, sodass aus 5d 120 h werden muss), und verwerfen Sie Loopback-Netze beim Import einer Liste vertrauenswürdiger Netze (ein Loopback-Eintrag verursacht eine Mail-Schleife).
Fügen Sie den Importer schließlich zu import::registry() hinzu und geben Sie ihm eine Zeile in contrib/feature-registry.tsv.
24.9.1. Einen Importer testen¶
Jeder Fixture-Baum liegt unter pepsi-setup/tests/fixtures/<mta>/<case>/ und ist eine Probe-Wurzel: Das Verzeichnis steht stellvertretend für /, sodass pepsi-setup import <mta> --root <that directory> von Hand genau das reproduziert, was die Tests ausführen. Geben Sie einem neuen Importer mindestens drei: eine realistische Installation, ein zweites Layout, das dieser Server unterstützt (eine aufgeteilte Konfiguration, eine generierte Datei, ein reiner Relay-Host), und einen feindseligen — abgeschnittene Zeilen, nicht abgeschlossene Anführungszeichen, binäres Rauschen, ein Verzeichnis dort, wo eine Datei hingehört, ein nicht lesbares Include. Jeder Baum, dessen Name hostile enthält, muss Warnungen erzeugen.
Danach greifen drei Suiten, und ein neuer Importer ist von zweien davon abgedeckt, sobald seine Fixtures vorliegen:
pepsi-setup/tests/import_pipeline.rsZählt jeden Fixture-Baum im Repository auf und hält alle Importer an die gemeinsamen Invarianten: Nur der zuständige Importer beansprucht einen Baum, jede Warnung trägt einen Betreff, eine Erklärung und eine Herkunft mit Dateinamen, die konvertierte Alias-Map lädt unter allen drei Qualifizierungsstilen mit
converted + droppedgleich der Anzahl der Quelleinträge, die vorbelegten Antworten sind nie leer, und der gerenderte Bericht verbucht jede Warnung, ohne Steuerzeichen auszugeben.pepsi-setup/tests/import_<mta>.rsDie Suite pro Server: was dieser Importer versteht, Ende zu Ende über die öffentliche API zugesichert.
tests/common/mod.rsstellt denCase-Helper bereit (Case::load("postfix", "postfix/basic")) mitwarned/not_warned-Zusicherungen —not_warnedist diejenige, die beweist, dass eine Direktive tatsächlich verarbeitet und nicht auf den UNKNOWN-Haufen gekehrt wurde.tests/cli_import.rsFührt das echte Binärprogramm aus: die
import-Vorschau, die Auswahl, wenn mehrere Mailserver installiert sind, und eine vollständige--wizard --import-Migration, deren erzeugte Konfiguration Pepsis eigene Validierung bestehen muss — der Assistent weigert sich, eine zu schreiben, die das nicht tut.
24.10. Einen externen Credential-Refresh-Dienst integrieren¶
Manche Stages authentifizieren sich bei einem vorgelagerten Dienst mit einem kurzlebigen Credential, das außerhalb des Bandes rotiert werden muss. Der Referenzfall ist das Smarthost-Relay (pepsi-stage-relay-to-smarthost) mit AUTH = oauth: Es liest bei jeder Zustellung ein frisches SASL-Bearer-Token aus einer TOKEN_FILE; dieses OAuth-Zugriffstoken läuft etwa stündlich ab. Pepsi liefert pepsi-helper-token-refresh(1), um die Datei aktuell zu halten, aber das ist nur eine Implementierung eines Vertrags — Sie können Ihren eigenen Refresher einsetzen (einen Cron-Job, der einen Token-Endpunkt anspricht, einen Cloud-Anbieter-Agenten, einen Secrets-Manager-Sidecar), solange er den unten stehenden Vertrag einhält.
24.10.1. Warum ein separater Dienst, keine Stage¶
Das Erneuern ist keine Arbeit pro Nachricht, es benötigt das Client-Geheimnis des Anbieters (das die Stage niemals sehen darf), und es sollte weiterlaufen, auch wenn keine Mail fließt. Daher ist es ein eigenständiges Programm, das als sein eigener, stärker vertrauenswürdiger Benutzer läuft, und es ist bewusst nicht Teil von pepsi.target: Ein Standort erneuert Tokens vielleicht bereits auf andere Weise. Aktivieren Sie es nur, wenn Pepsi das Erneuern übernehmen soll.
24.10.2. Der Vertrag¶
Jeder Refresher — der von Pepsi oder Ihr eigener — muss vier Dinge erfüllen:
Halten Sie die Client-Geheimnisse aus der Stage heraus. Legen Sie die Konfiguration der Credential-Quelle in eine separate Datei und ziehen Sie sie mit der Taler-Direktive
@inline-secret@ <SECTION> <FILE>inpepsi.conf(siehe pepsi.conf(5)). Machen Sie diese Datei nur für den Benutzer des Refreshers lesbar.@inline-secret@ist genau hier besonders: Ein Leser, der die Datei nicht öffnen kann (die Stage, die alspepsiläuft), überspringt den Abschnitt stillschweigend und parst den Rest der Konfiguration normal, während ein einfaches@inline@einen Fehler ergäbe. So erreicht das Geheimnis nie die Stage, und doch teilen sich beide Programme einepepsi.conf.Schreiben Sie das Credential dort, wo die Stage es liest, atomar. Ersetzen Sie den Inhalt der
TOKEN_FILEper In-temporäre-Datei-schreiben-dann-rename, damit die Stage, die jederzeit lesen kann, nie eine unvollständige Datei sieht. Der Dateiinhalt ist genau das Credential, das die Stage erwartet (für OAuth: das nackte Zugriffstoken, von Leerraum befreit).Setzen Sie Gruppe und Modus richtig. Die Token-Dateien liegen in einem Verzeichnis, das SGID auf die
pepsi-token-Gruppe ist (Standardwert/var/pepsi/tokens), sodass eine neue Datei diese Gruppe erbt; schreiben Sie sie mit Modus0640. Das verbrauchende Stage-Binärprogramm wird SGIDpepsi-tokeninstalliert, was es dem unprivilegiertenpepsi-Worker des Dispatchers erlaubt, das Token zu lesen — ohnepepsizum dauerhaften Mitglied der Gruppe zu machen. Jedes langlebige Geheimnis, das der Refresher für sich selbst persistiert (z. B. ein rotiertes Refresh-Token), gehört in ein privates Verzeichnis (Modus0700), niemals in das gruppenlesbare Token-Verzeichnis.Fail-safe. Lassen Sie bei einem Refresh-Fehler die vorherige Token-Datei an Ort und Stelle und wiederholen Sie mit Backoff; kürzen Sie niemals ein funktionierendes Token, weil der Endpunkt kurzzeitig nicht erreichbar war. Die Relay-Stage behandelt ein fehlendes/abgelaufenes Token bereits als vorübergehenden Zustellfehler, sodass die Nachricht einfach wartet und wiederholt, sobald ein frisches Token eintrifft.
24.10.3. Den mitgelieferten Refresher ersetzen¶
Um Ihre eigene Implementierung einzusetzen, lassen Sie den pepsi-helper-token-refresh-Dienst deaktiviert (er wird deaktiviert ausgeliefert) und lassen Ihr Programm dieselbe TOKEN_FILE mit denselben Berechtigungen schreiben. Nichts in der Stage, im Ingress oder im Dispatcher ändert sich — die Stage liest die Datei nur. pepsi-setup gibt eine Erinnerung aus, wann immer ein Smarthost AUTH = oauth verwendet, damit Sie nicht vergessen, einen Refresher anzubinden.
Dasselbe Muster verallgemeinert sich auf jede künftige Stage, die ein periodisch rotiertes Geheimnis benötigt: Geben Sie ihr eine *_FILE-Option, lesen Sie die Datei pro Verwendung, legen Sie die ausstellenden Zugangsdaten hinter @inline-secret@ und betreiben Sie den Aussteller als eigenen Benutzer, der durch eine gruppenweit begrenzte, für die SGID-Stage lesbare Datei schreibt.
24.10.4. Kerberos (AUTH = gssapi)¶
Das AUTH = gssapi des Smarthost-Relays folgt demselben externen Refresh-Vertrag, aber das rotierte Credential ist ein Kerberos-Ticket in einem Credential-Cache statt einer Token-Datei, und der Refresher ist ein Standardwerkzeug statt eines Pepsi-Binärprogramms — daher liefert Pepsi keines mit. Der Vertrag lautet:
Beschaffen und erneuern Sie das Ticket außerhalb des Bandes. Führen Sie
k5start/krenew(oder einenkinit -k-Cron-Job) unter einem stärker vertrauenswürdigen Benutzer aus und authentifizieren Sie sich aus einem Keytab, das die Relay-Stage niemals liest. Dies beschafft und erneuert periodisch ein Ticket-Granting-Ticket für Pepsis eigenen Prinzipal.Schreiben Sie den Cache dort, wo die Stage ihn liest. Richten Sie
k5startauf einenFILE:-Credential-Cache im SGID-pepsi-token-Verzeichnis/var/pepsi/krb5(sodass die Datei die Gruppe erbt) und benennen Sie ihn in derKRB5CCNAME-Option des MTA (oder setzen SieKRB5CCNAMEin der Dienstumgebung des Dispatchers für den Standard-Cache). Das SGID-Smarthost-Stage-Binärprogramm — bereits SGIDpepsi-tokenfür OAuth/mTLS — ist das, was dempepsi-Worker das Lesen erlaubt.Fail-safe. Ein fehlendes oder abgelaufenes Ticket macht die Zustellung zu einem vorübergehenden Fehler, sodass die Nachricht wartet und wiederholt, sobald ein gültiges Ticket im Cache ist.
pepsi-setup gibt eine Erinnerung aus (und prüft die Lesbarkeit des FILE:-Caches auf Plausibilität), wann immer ein Smarthost AUTH = gssapi verwendet.
24.11. Lebenszyklus des Datenbankschemas¶
Das Schema ist eine Reihe nummerierter Patch-Dateien unter dem SQL-Präfix pepsi (in pepsi-setup/db/):
pepsi-NNNN.sql— die nummerierten Patches.pepsi-setupwendet sie der Reihe nach an (pepsi-0001.sql,pepsi-0002.sql, …), bis einer fehlt.procedures.sql— neu erzeugbare gespeicherte Funktionen (CREATE OR REPLACE), bei jedem Lauf erneut angewendet.drop.sql—DROP SCHEMA pepsi CASCADE, nur vonrun --resetverwendet.versioning.sql— die Mechanik zur Patch-Verfolgung.
Angewendete Patches werden (unter ihrem register_patch-Namen) vermerkt, sodass wiederholte Läufe idempotent sind.
Die Patch-Nummer wird nicht bei jeder Schemaänderung erhöht. Eine neue pepsi-NNNN.sql wird nur für die erste Schemaänderung nach einem Release begonnen — also nach einem Git-Tag der Form v$MAJ.$MIN.$REV. Zwischen Releases wird die aktuell oberste Patch-Datei an Ort und Stelle bearbeitet: Keine veröffentlichte Installation trägt dieses noch unveröffentlichte Schema, daher können seine Anweisungen frei ergänzt oder umgeschrieben werden. Sobald ein Release getaggt ist, ist diese Datei eingefroren, und die nächste Änderung, die das Schema berührt, wird zu pepsi-(NNNN+1).sql, deren register_patch den vorherigen Patch als Abhängigkeit deklariert — so migrieren gegen das veröffentlichte Schema installierte Datenbanken schrittweise vorwärts (mit ALTER), während eine Neuinstallation weiterhin jeden Patch der Reihe nach anwendet.
Das erste Release, v0.0.0, lieferte das gesamte Schema als die einzige pepsi-0001.sql aus (nur CREATE, kein ALTER/DROP), die daher eingefroren ist. Die nächste Schemaänderung beginnt pepsi-0002.sql, registriert mit register_patch('pepsi-0002', ARRAY['pepsi-0001'], NULL) (halten Sie den Dateinamen und seinen register_patch-Namen im Gleichschritt), und bringt eine v0.0.0-Datenbank mit ALTER nach vorn.
Die Schemaprüfung. Das Build-Skript von pepsi-common bildet über jede pepsi-NNNN.sql und über procedures.sql einen Hash und bettet ihn in die Bibliothek ein, der Installer hält Hash und Release jeder angewendeten Datei in pepsi.schema_file fest, und jeder Pool, den ein Programm öffnet, vergleicht beides (pepsi_common::schema). Es gibt also keine Schemaversion, die hochzuzählen wäre: Jede Änderung an einer SQL-Datei ändert, was der nächste Build erwartet, und ein Programm weist eine Datenbank, die aus etwas anderem installiert wurde, mit Exit-Status 78 zurück (siehe Upgrade). Zwei Folgen für die Entwicklung: Führen Sie nach einer Änderung an procedures.sql erneut pepsi-setup schema (oder make check) aus, bevor Sie Programme gegen eine bestehende Datenbank starten; und ein Test, der sich eine eigene Datenbank aufbaut, muss sie mit pepsi_common::schema::dbinit installieren – wie es das Test-Harness tut –, statt die Dateien von Hand anzuwenden. Die einzigen Pools, die die Prüfung auslassen, sind db::pool_unchecked: der Installer und der Setup-Applier.
Ein Release einfrieren. Im Release-Commit:
hängen Sie die Patch-Dateien des Releases samt ihrem SHA-256 an
pepsi-setup/db/RELEASEDan (TAG FILE SHA256je Zeile). Ein Unit-Test schlägt fehl, wenn sich eine aufgeführte Datei je ändert, und ein Programm, das auf eine Datenbank trifft, die aus einer anderen Fassung eines aufgeführten Patches gebaut wurde, meldet einen Fehler im Programm, statt zu einem Zurücksetzen zu raten;frieren Sie das Upgrade-Fixture mit
tests/upgrade/freeze.sh vX.Y.Zein (siehetests/upgrade/README). Der CI-Job4-upgradebringt Schema und Fixture des vorigen Releases auf den Stand des getesteten Baums, prüft, dass jeder Datensatz erhalten bleibt und sich die vor dem Upgrade angelegte Sicherung wiederherstellen lässt, leert die alte Warteschlange mit dem neuen Dispatcher und prüft, dass das alte Release das Ergebnis zurückweist.
Eingereihte Datensätze überleben ein Release. Ein Upgrade leert die Warteschlange nicht, daher treffen die Stages des neuen Releases auf Datensätze – und deren state-JSON –, die das alte geschrieben hat. Benennen Sie einen state-Schlüssel, den ein eingereihter Datensatz tragen kann, niemals um, entfernen Sie ihn nicht und ändern Sie nicht seinen Typ; fügen Sie stattdessen einen neuen Schlüssel hinzu, und sorgen Sie dafür, dass jeder Leser einen fehlenden Schlüssel toleriert. tests/upgrade/seed.sql sollte jeden Schlüssel enthalten, den eine Stage schreibt, damit der Upgrade-Test ihn sieht.
24.12. Die Funktionsstabilitätstabelle aktuell halten¶
Jede für den Benutzer sichtbare Funktion ist in der einzigen Funktionsstabilität-Tabelle aufgeführt. Diese Seite wird von contrib/update-feature-stability.sh generiert, das zwei Quellen zusammenführt:
das von Hand gepflegte Register
contrib/feature-registry.tsv— eine Zeile pro Funktion, mit ihremkey(dem stabilen Telemetrie-Bezeichner), dem menschenlesbaren Namen, dem maßgeblichen RFC, der Abdeckung durch automatisierte Tests (U/I/I/U/-), dem Status des manuellen Tests (yes/no) und ob sie für Telemetrie instrumentiert ist (yes/no); undein
pepsi-telemetry-GET /telemetry/report— die Verbreitungs- und Nutzungszahlen pro Funktion, überkeymit dem Register verbunden.
Eine Funktion, deren telemetry-Spalte no ist, lässt sich nicht sinnvoll zählen. Eine solche Zeile wird mit einem (*) hinter dem Namen und einem — in den Zellen Installationen, Nutzungen und Stabilität gerendert; sie hat keinen telemetry_update-Aufruf. Es gibt vier Gründe, warum eine Zeile in diesem Zustand ist, und der Grund muss neben dem Schlüssel in KNOWN_UNINSTRUMENTED (tests/feature_registry.rs) niedergeschrieben werden — ein Test prüft, dass Liste und Spalte übereinstimmen, damit sich „noch nicht gemessen“ von „wird nie gemessen werden“ unterscheiden lässt:
Eine passive Protokollfähigkeit, deren Nutzung der Client nie signalisiert (
ENHANCEDSTATUSCODES). Beachten Sie, dassPIPELININGkeine davon ist: Ein Client, der einen Befehl sendet, ohne auf die Antwort zu warten, ist unmittelbar beobachtbar, und Ingress meldet das einmal je Sitzung.Eine universelle Einrichtung, deren Zähler eher Prozessstarts als Nutzung messen würde — strukturiertes Logging, der Telemetriekanal selbst,
pepsi-setup(jede Installation führt es aus, sodass der Zähler die Installationszahl verdoppeln würde).Eine Eigenschaft einer Konfiguration oder eines Schemas statt eines Ereignisses: die
pepsi-crypto-Berechtigung auf der Spalte mit den privaten Schlüsseln, deren „Nutzung“ das Ausbleiben eines Berechtigungsfehlers ist.Ein kurzlebiger Prozess: ein Operator-CLI oder ein privilegiertes
pepsi-helper-*. Dies ist eine harte Grenze der Produzenten-API.
telemetry_update übergibt den Funktionsnamen an eine Hintergrundaufgabe und kehrt zurück; die Aufgabe braucht dann einen Durchgang der Tokio-Laufzeit, um sich mit dem lokalen pepsi-telemetry-client-Socket zu verbinden und zu schreiben. Ein Prozess, der unmittelbar nach dem Melden aus seinem block_on zurückkehrt, wird verworfen, bevor dieser Durchgang stattfindet, und das Ereignis geht verloren — gemessen mit 0 Zustellungen in 200 Läufen, gegenüber 30 von 30, wenn der Prozess eine Millisekunde länger in der Laufzeit bleibt (pepsi-common/tests/telemetry_short_lived.rs hält das fest). Die Schwelle lautet also nicht „der Prozess muss eine Weile leben“, sondern „die Laufzeit muss noch einmal durchlaufen“. Ein langlebiger Daemon oder Stage-Worker ist nicht betroffen; ein CLI kann genau das nicht melden, was es soeben getan hat — der einzige interessante Moment, den es hat. Die Operatorwerkzeuge zu verdrahten erfordert daher zuerst ein begrenztes, abgewartetes flush() in der Produzenten-API — und einen Maßstabsbegriff je Zeile, denn die Stabilitätsstufen sind auf Nachrichten geeicht (1 000 Nutzungen für used, 100 000 für stable), und ein Befehl, den ein Mensch ein Dutzend Mal im Jahr ausführt, bliebe von einem einwandfrei arbeitenden Zähler für immer auf experimental festgenagelt.
Die Programme, die die Senke tatsächlich installieren und daher melden können, sind pepsi-ingress, die Stage-Worker (über run_worker), pepsi-dispatch, pepsi-httpd, pepsi-keydisc, pepsi-failure-bouncer in seinem Dienstmodus, der Applier pepsi-setup apply und der Assistent pepsi-setup, sobald er eine Konfiguration geschrieben hat, deren SHARE_TELEMETRY-Antwort er zurücklesen kann. Irgendetwas aus dem Interview zu melden, bevor der Operator geantwortet hat, hieße, die Aktivität einer Installation zu übermitteln, die nie zugestimmt hat — genau das, was Opt-in verhindern soll.
Wann immer Sie den Code oder die Tests ändern, halten Sie die Tabelle ehrlich:
Eine Funktion hinzufügen. Fügen Sie
contrib/feature-registry.tsveine Zeile hinzu. Sofern die Funktion nicht wirklich nicht instrumentierbar ist (setzen Sietelemetry=no— reservieren Sie dies für die obigen Fälle und tragen Sie den Schlüssel mit dem Grund inKNOWN_UNINSTRUMENTEDintests/feature_registry.rsein), setzen Sietelemetry=yesund rufentelemetry_update("<key>")an einer geeigneten Stelle im Code auf — irgendwo, wo die Funktion wirklich läuft, sodass der Zähler echte Nutzung widerspiegelt (und der „aktiviert“-Schnappschuss die Installationen widerspiegelt, die sie tatsächlich nutzen). Verwenden Sie in einer Stagectx.telemetry_update("<key>")an der Stelle, an der die Stage ihre charakteristische Arbeit verrichtet; andernorts rufen Siepepsi_common::telemetry::telemetry_update("<key>")direkt auf (Ingress tut dies fürstarttls,8bitmime, die eingehenden Auth-Prüfungenspf/dkim-verify/dmarc/iprev/auth-resultsund der Relay-Client fürdane,tlsrptundtls-identity). Diekey-Zeichenkette im Code und die im Register müssen übereinstimmen — so heften sich die Telemetriezahlen an die Zeile, und diestderr-Warnung des Generators (unten) erkennt eine Abweichung.Einen Test schreiben. Aktualisieren Sie die
test-Spalte dieser Funktion:U, wenn Sie einen Unit-/In-Process-cargo testhinzugefügt haben,Ifür eintests/*.sh-Live-Pipeline-Skript oder einenpepsi-test-stages-Ende-zu-Ende-Test,I/Ufür beides.Manuell testen. Setzen Sie die
manual-Spalte der Funktion aufyes.Die Zähler auffrischen. Führen Sie
contrib/update-feature-stability.shaus (es ruftTELEMETRY_URLab oder liest eineTELEMETRY_REPORT_FILE; mit keinem von beiden ist jeder Zähler0und jede Funktionexperimental) und committen Sie das neu generiertedocs/manual/feature-stability.rst. Das Skript warnt aufstderrüber jede Telemetrie-Funktion ohne Registerzeile, sodass ein verirrter oder umbenannterkeyerkannt wird.
Bearbeiten Sie docs/manual/feature-stability.rst nicht von Hand — es wird bei jeder Neugenerierung überschrieben.
cargo test --test feature_registry erzwingt all das ohne eine Telemetrie-Installation: Jeder gemeldete Schlüssel hat eine Zeile, jede Zeile ist aus den Quellen erreichbar, kein Schlüssel steht auf KNOWN_UNINSTRUMENTED, während der Code ihn meldet, und die Liste und die telemetry-Spalte sagen dasselbe.