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:

  1. Bibliotheks-Crate pepsi-stage-<name> mit:

    • constants.rs — ein CONFIG_SOURCE und eine PROGRAM-Zeichenkette (der Binärname, den Operatoren in PROGRAM = eintragen).

    • config.rs (optional) — eine Struktur, die aus dem Section der Stage für etwaige programmspezifische Optionen geparst wird.

    • lib.rs — der Stage-Rumpf async fn body(ctx: StageContext<'_>) -> anyhow::Result<()>, eine LOAD-Konstante und der einzige dünne Einstiegspunkt worker (run_worker).

  2. Programm-Modul src/programs/stage_<name>.rs im Wurzelpaket pepsi: die clap-Args (#[clap(flatten)] common: PepsiArgs, gefolgt von #[command(subcommand)] cmd: Command, dessen einzige Variante Worker ist) und ein pub fn main(), das pepsi_main(CONFIG_SOURCE, args.common, …) aufruft. Registrieren Sie das Modul in src/programs/mod.rs.

  3. Binärprogramm src/bin/pepsi-stage-<name>.rs — ein dünnes fn main() { pepsi::programs::stage_<name>::main(); } mit einem [[bin]]-Eintrag samt required-features = ["multibin"] in der Workspace-Cargo.toml. Das ist das Entwicklungs-Layout; der Release-Build faltet das Programm stattdessen in das Multi-Call-Binärprogramm pepsi ein, fügen Sie also auch einen Zweig zu run_applet und einen Namen zu APPLETS in src/bin/pepsi.rs hinzu.

  4. Registrieren Sie die Bibliotheks-Crate in der Workspace-Cargo.toml und fügen Sie den Programmnamen zu FOLDED_BINARIES im Makefile hinzu (oder zu STANDALONE_BINARIES, wenn es ein eigenes setuid-/setgid-Bit tragen muss, was ein gemeinsames Binärprogramm nicht kann, oder wenn es separat ausgeliefert oder per exec gestartet wird).

  5. (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 Makro fusion_entry! in src/fusion.rs, dessen install() 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 das FUSION ihres Abschnitts sagt. Fusion verlangt, dass die Crate eine öffentliche Konstante LOAD und eine öffentliche Rumpffunktion exportiert, und sie wird zur Laufzeit verweigert, sofern das Load des 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, optional headers, optional body, stage, status, state, settings_map, age_secs sowie die Helper is_bounce() und state_value(). headers wird nur unter Load::Headers/Load::Full gefüllt und body nur unter Load::Full; bevorzugen Sie die Zugriffsfunktionen ctx.headers() / ctx.raw_message(), die das auch sagen, statt Ihnen ein None zu reichen.

  • ctx.section() — der [stage-<name>]-Abschnitt der Stage für Ihre Optionen.

  • ctx.headers() — der Header-Block (erfordert Load::Headers oder Load::Full).

  • ctx.raw_message() — die wieder zusammengesetzte Nachricht (erfordert Load::Full).

  • ctx.require_next_stage() — das konfigurierte NEXT_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 (auf NULL setzen) oder set_auth_verdict(key, val) / set_auth_fields(...) (setzen ein oder mehrere state.auth.<key>-Mitglieder; rufen Sie es einmal pro Body auf, da jeder Aufruf auth aus dem geladenen Datensatz neu aufbaut) auf. Die Änderung wird in den In-Memory-Datensatz eingefaltet und vom Worker zusammen mit Ihrer terminalen Transition in einem UPDATE geschrieben. 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_out oder split_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 mit bail! ab, wenn eine aussteht. (Das schlichte finish() 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_TELEMETRY zugestimmt 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

advance() / advance_to(s)

Verschiebt zu NEXT_STAGE (oder s) und setzt den Datensatz dort auf ``pending``; der Worker-Pool der nächsten Stage beansprucht ihn. Die Stage besitzt diesen Übergang von Ende zu Ende — der einzige Schreibvorgang des Dispatchers auf status, der Fortschritt bewirkt, ist die Beanspruchung pending→running, nie umgekehrt. (Ist der Nachfolger fusioniert, wird bei diesem Hop überhaupt kein Datensatz geschrieben; die Kette wird einmal festgeschrieben.)

reroute(stage, state)

Verschiebt zu stage unter Zusammenführen von state; verwendet zur Übergabe an eine Bounce-Stage.

pause(state, secs)

paused + ein Wiederholungs-timeout; der Dispatcher reiht sie später erneut ein.

pause_with(merge, secs, keep, clones)

Pausiert und reduziert im selben Round-Trip den Datensatz auf eine Empfänger-Teilmenge und/oder erzeugt seitliche SideClones (eine Verzögerungs-DSN, Erfolgs-DSNs für die bereits zugestellten Empfänger).

fail(state)

Terminal failed, für einen Operator belassen.

finish()

DELETE des Datensatzes (Nachricht verbraucht). finish_with(clones) löscht und erzeugt in einem Aufruf seitliche Klone (Bounces je Empfänger, Erfolgs-DSNs).

set_pending()

Gibt den Datensatz bei derselben Stage als pending zurück, damit er erneut beansprucht und neu geladen wird. Wird nach einer Aufteilung verwendet, sodass der nächste Durchlauf den reduzierten Datensatz sieht.

advance_or_finish()

Schaltet weiter, falls ein NEXT_STAGE existiert, sonst abschließen.

complete_success(dsn, diag, rcpt)

Erfolg im Zustellstil: gibt eine positive DSN aus, wenn konfiguriert, sonst advance_or_finish.

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), wobei check(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 von ctx.section() in ctx.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 einzigen UPDATE fest, 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 (nicht set_state), überschreiben Sie state also nicht komplett, es sei denn, Sie prägen absichtlich eine neue Nachricht (nur die Bounce-Stage tut das, über clear_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 NOTIFY des Empfängers über pepsi_common::dsn abhä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 auf pending zurü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:

  1. Er kann nicht fehlschlagen. import liefert Imported zurück, nicht Result. Jede nicht lesbare Datei, jede nicht parsebare Zeile, jede nicht erkannte Direktive und jede nicht unterstützte Funktion wird als Warning am Modell festgehalten — mitsamt der file: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.

  2. 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 mit Imported::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 Sie Imported::unsupported und sagen Sie in einem Satz, was an ihre Stelle tritt (meist eine Stage).

  3. 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 unter pepsi-setup/tests/fixtures/<mta>/ als Unit zu testen, ohne Zugriff auf das echte System.

  4. Routing-Tabellen werden nicht vom Importer klassifiziert. Parsen Sie sie zu RawAlias { key, targets, origin }-Werten (import::aliases hat Parser für die beiden universellen Dialekte) und lassen Sie import::aliases::convert entscheiden, 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.rs

Zä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 + dropped gleich 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>.rs

Die Suite pro Server: was dieser Importer versteht, Ende zu Ende über die öffentliche API zugesichert. tests/common/mod.rs stellt den Case-Helper bereit (Case::load("postfix", "postfix/basic")) mit warned/not_warned-Zusicherungen — not_warned ist diejenige, die beweist, dass eine Direktive tatsächlich verarbeitet und nicht auf den UNKNOWN-Haufen gekehrt wurde.

tests/cli_import.rs

Fü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:

  1. 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> in pepsi.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 als pepsi lä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 eine pepsi.conf.

  2. Schreiben Sie das Credential dort, wo die Stage es liest, atomar. Ersetzen Sie den Inhalt der TOKEN_FILE per 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).

  3. 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 Modus 0640. Das verbrauchende Stage-Binärprogramm wird SGID pepsi-token installiert, was es dem unprivilegierten pepsi-Worker des Dispatchers erlaubt, das Token zu lesen — ohne pepsi zum 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 (Modus 0700), niemals in das gruppenlesbare Token-Verzeichnis.

  4. 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:

  1. Beschaffen und erneuern Sie das Ticket außerhalb des Bandes. Führen Sie k5start/krenew (oder einen kinit -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.

  2. Schreiben Sie den Cache dort, wo die Stage ihn liest. Richten Sie k5start auf einen FILE:-Credential-Cache im SGID-pepsi-token-Verzeichnis /var/pepsi/krb5 (sodass die Datei die Gruppe erbt) und benennen Sie ihn in der KRB5CCNAME-Option des MTA (oder setzen Sie KRB5CCNAME in der Dienstumgebung des Dispatchers für den Standard-Cache). Das SGID-Smarthost-Stage-Binärprogramm — bereits SGID pepsi-token für OAuth/mTLS — ist das, was dem pepsi-Worker das Lesen erlaubt.

  3. 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-setup wendet 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 von run --reset verwendet.

  • 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/RELEASED an (TAG FILE SHA256 je 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.Z ein (siehe tests/upgrade/README). Der CI-Job 4-upgrade bringt 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 ihrem key (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); und

  • ein pepsi-telemetry-GET /telemetry/report — die Verbreitungs- und Nutzungszahlen pro Funktion, über key mit 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, dass PIPELINING keine 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:

  1. Eine Funktion hinzufügen. Fügen Sie contrib/feature-registry.tsv eine Zeile hinzu. Sofern die Funktion nicht wirklich nicht instrumentierbar ist (setzen Sie telemetry = no — reservieren Sie dies für die obigen Fälle und tragen Sie den Schlüssel mit dem Grund in KNOWN_UNINSTRUMENTED in tests/feature_registry.rs ein), setzen Sie telemetry = yes und rufen telemetry_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 Stage ctx.telemetry_update("<key>") an der Stelle, an der die Stage ihre charakteristische Arbeit verrichtet; andernorts rufen Sie pepsi_common::telemetry::telemetry_update("<key>") direkt auf (Ingress tut dies für starttls, 8bitmime, die eingehenden Auth-Prüfungen spf / dkim-verify / dmarc / iprev / auth-results und der Relay-Client für dane, tlsrpt und tls-identity). Die key-Zeichenkette im Code und die im Register müssen übereinstimmen — so heften sich die Telemetriezahlen an die Zeile, und die stderr-Warnung des Generators (unten) erkennt eine Abweichung.

  2. Einen Test schreiben. Aktualisieren Sie die test-Spalte dieser Funktion: U, wenn Sie einen Unit-/In-Process-cargo test hinzugefügt haben, I für ein tests/*.sh-Live-Pipeline-Skript oder einen pepsi-test-stages-Ende-zu-Ende-Test, I/U für beides.

  3. Manuell testen. Setzen Sie die manual-Spalte der Funktion auf yes.

  4. Die Zähler auffrischen. Führen Sie contrib/update-feature-stability.sh aus (es ruft TELEMETRY_URL ab oder liest eine TELEMETRY_REPORT_FILE; mit keinem von beiden ist jeder Zähler 0 und jede Funktion experimental) und committen Sie das neu generierte docs/manual/feature-stability.rst. Das Skript warnt auf stderr über jede Telemetrie-Funktion ohne Registerzeile, sodass ein verirrter oder umbenannter key erkannt 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.