18. Extending the Pipeline¶
Because the pipeline is a state machine over a single table, adding a processing
step does not touch ingress, the dispatcher or any other stage: you write a new
stage program and splice its [stage-<name>] section into the graph. This
chapter is the recipe.
A stage program is a persistent worker the dispatcher starts as PROGRAM
worker; it reads one ingress_id per line of standard input and writes one
status line per message to standard output (0 on success). For each id it
must: load the row, do its work, and call exactly one terminal helper. The
same body also runs once as PROGRAM <ingress_id> for manual operation and
tests. Everything shared — the stdin/stdout worker loop, opening the pool once,
loading the row, verifying the section — is provided by pepsi_common::stage
(run_worker / run_oneshot). Standard output is the worker protocol
channel, so a stage body must never write to it (log to standard error).
18.1. When a stage is the right tool¶
Write a stage when you want to inspect or transform a message in flight, or make a routing decision, e.g.:
a content filter (spam/virus) that
pauses,fails or advances;a header rewriter or archival/audit hook;
an alternative delivery method (a new transport);
an integration that calls out to another service per message.
If you only need a different wiring of existing stages, you do not need code at
all — just new [stage-*] sections.
18.2. Anatomy of a stage crate¶
Follow the existing stages (pepsi-stage-srs is the smallest). A stage is a
library crate plus a thin binary in the workspace root:
Library crate
pepsi-stage-<name>with:constants.rs— aCONFIG_SOURCEand aPROGRAMstring (the binary name operators put inPROGRAM =).config.rs(optional) — a struct parsed from the stage’sSectionfor any program-specific options.lib.rs— the stage bodyasync fn body(ctx: StageContext<'_>) -> anyhow::Result<()>, aLOADconstant, and the two thin entry pointsrun(run_oneshot) andworker(run_worker).
Binary
src/bin/pepsi-stage-<name>.rsthat callstaler_main, exposes aworkersubcommand delegating to the library’sworker, and runs the library’srunfor a bare<message_id>argument.Register the crate in the workspace
Cargo.tomland add the binary to theMakefile’sBINARIESlist.
18.3. The body¶
A stage body is uniform. The two entry points just name the body and how much of the message to load; the body acts and terminates:
// 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;
/// One-shot: process a single id (manual operation, tests).
pub async fn run(message_id: i64, cfg: &Config) -> anyhow::Result<()> {
stage::run_oneshot(PROGRAM, message_id, cfg, LOAD, act).await
}
/// Persistent dispatcher worker: read ids from stdin until EOF.
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.ingress_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
}
Key pieces of the StageContext API:
ctx.message— the loaded row:ingress_id,mail_from,rcpt_to,from_header,subject, optionalheaders, optionalbody,state,age_secs, and helpersis_bounce()andstate_value().ctx.section()— the stage’s[stage-<name>]section for your options.ctx.headers()— the header block (requiresLoad::HeadersorLoad::Full).ctx.raw_message()— the reassembled message (requiresLoad::Full).ctx.require_next_stage()— the configuredNEXT_STAGE.Content setters — to rewrite the message, never issue SQL: record the change with
set_mail_from,set_recipients,set_from_header,set_subject,set_headers,set_body,set_state(replace),merge_state(shallow||merge),clear_state(setNULL), orset_auth_verdict(key, val)(setstate.auth.<key>). The change is folded into the in-memory row and written by the worker together with your terminal transition in oneUPDATE. A content rewrite must therefore end in an advancing/failing terminal (advance/reroute/fail/set_pending), never a fan-out one (finish/pause/split_off_recipients), which would not persist it.ctx.telemetry_update("<key>")— record that your feature was exercised (non-blocking, infallible, and a no-op unless the operator opted in by turning[pepsi] SHARE_TELEMETRYon, which is not the default). Call it where the feature actually does its work, with the stable key you registered for it (see Keeping the feature-stability table current below).
18.4. Terminal helpers (choose exactly one)¶
The terminal helper you call is the contract with the dispatcher:
Helper |
Effect |
|---|---|
|
Move to |
|
Move to |
|
|
|
Terminal |
|
|
|
Advance if a |
|
Delivery-style success: emit a positive DSN when configured, else
|
18.5. Rules the stages live by¶
To stay consistent with the rest of the pipeline, honour these invariants:
One SELECT, one UPDATE. Pick the right
Load; record content changes via the setters and end with one terminal helper. The worker commits every change and the transition in a singleUPDATEbuilt from exactly the columns you touched — don’t issue your own SQL or add extra round-trips.Preserve ``state.dsn`` (and other keys) — use
merge_state(notset_state), so don’t overwritestatewholesale unless you are intentionally minting a new message (only the bounce stage does that, viaclear_state).Never bounce a bounce. Check
ctx.message.is_bounce()before producing a notification; a null-sender message is dropped, not re-bounced (RFC 5321 §6.1).Honour DSN ``NOTIFY``. If you originate notifications, gate them on the recipient’s
NOTIFYviapepsi_common::dsn(failure by default; success/delay only on explicit request).Fail-open where a stuck message is worse than an imperfect one (the ARC and DKIM-sign stages advance with a warning rather than blocking) — but only when that is safe for your step.
Be idempotent / restart-safe. A stage may be retried after a crash; design so re-running on the same row is safe (the dispatcher resets orphaned
runningrows topending).
18.6. Wiring it in¶
Build and install the new binary, then add a stage section and point a neighbour at it:
[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
Run pepsi-setup ... check (and pepsi-config ... dump) to validate the graph — it
verifies that [stage-init] exists and every NEXT_STAGE/BOUNCE_STAGE
resolves. No ingress or dispatcher change is needed; the next message simply
flows through your new stage.
18.7. Testing a stage¶
Follow the project’s testing constraints: never send real mail to third-party MX
(use reserved/.invalid domains), and remember the dev uid cannot bind ports
25/53. Unit-test the pure logic in your library; for an end-to-end check, run a
throwaway PostgreSQL, install the schema with pepsi-setup, insert a row, and
invoke the binary on its id directly (pepsi-stage-<name> <id> -c test.conf) —
the global -c flag must follow the message-id/subcommand.
18.8. Adding support for migrating from another MTA¶
pepsi-setup can import an existing mail server’s configuration as the
wizard’s answer defaults (see Installation and
pepsi-setup). Five servers are supported out of the box —
Postfix, Exim, Sendmail, qmail and Stalwart — and adding a sixth is one new file
plus one line in a registry.
An importer implements 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;
}
Four rules make the difference between an importer that helps and one that misleads:
It cannot fail.
importreturnsImported, notResult. Every unreadable file, unparseable line, unrecognised directive and unsupported feature is recorded as aWarningon the model — with thefile:lineit came from — and the partially-filled model is returned anyway. A migration that understood nine settings out of ten is still worth having, and no configuration file on anyone’s disk may be able to abort setup.Every directive is accounted for. Consume it, list it in the module’s
IGNOREDtable of genuinely irrelevant knobs, or report it withImported::unknown. Silence about a setting the operator relies on is the failure mode this feature exists to prevent. For features Pepsi has no equivalent for, useImported::unsupportedand say in one sentence what replaces it (usually a stage).All filesystem access goes through
Probe. It handles size limits, non-UTF-8 bytes and permissions, and — because it can be rooted at a directory — it is what lets the importer be unit-tested against a fixture tree underpepsi-setup/tests/fixtures/<mta>/with no access to the real system.Routing tables are not classified by the importer. Parse them into
RawAlias { key, targets, origin }values (import::aliaseshas parsers for the two universal dialects) and letimport::aliases::convertdecide what Pepsi can express. It is the single place that knows Pepsi’s alias-key grammar, the qualification styles, and how to report a target — a|command, a file, a broken:include:— that has no equivalent.
Fill in what you learned on Imported: identity, domains, direction,
smarthost (including credentials, always recording password_source), local
delivery, aliases, submission identities, and any option in the
crate::advanced registry via set_advanced — the shared code turns all of
it into wizard defaults, converted map files and the migration report. Two
traps worth naming: convert durations with text::pepsi_duration (Pepsi’s
duration parser rejects the calendar units d and w, so 5d must
become 120 h), and drop loopback networks when importing a trusted-network
list (they have caused a mail loop before).
Finally, add the importer to import::registry() and give it a row in
contrib/feature-registry.tsv.
18.8.1. Testing an importer¶
Every fixture tree lives at pepsi-setup/tests/fixtures/<mta>/<case>/ and
is a probe root: the directory stands in for /, so
pepsi-setup import <mta> --root <that directory> reproduces by hand exactly
what the tests run. Give a new importer at least three: a realistic
installation, a second layout that server supports (a split configuration, a
generated file, a relay-only host), and a hostile one — truncated lines,
unterminated quoting, binary noise, a directory where a file belongs, an
unreadable include. Any tree whose name contains hostile is required to
produce warnings.
Three suites then apply, and a new importer is covered by two of them the moment its fixtures land:
pepsi-setup/tests/import_pipeline.rsEnumerates every fixture tree in the repository and holds all importers to the shared invariants: only the owning importer claims a tree, every warning carries a subject, an explanation and a file-naming origin, the converted alias map loads under all three qualification styles with
converted + droppedequal to the number of source entries, seeded answers are never empty, and the rendered report accounts for every warning without emitting control characters.pepsi-setup/tests/import_<mta>.rsThe per-server suite: what this importer understands, asserted end to end through the public API.
tests/common/mod.rsprovides theCasehelper (Case::load("postfix", "postfix/basic")) withwarned/not_warnedassertions —not_warnedis the one that proves a directive was consumed rather than swept into the UNKNOWN pile.tests/cli_import.rsRuns the real binary: the
importpreview, the chooser when several mail servers are installed, and a full--wizard --importmigration whose generated configuration must pass Pepsi’s own validation — the wizard refuses to write one that does not.
18.9. Integrating an external credential-refresh service¶
Some stages authenticate to an upstream with a short-lived credential that
must be rotated out of band. The reference case is the smarthost relay
(pepsi-stage-relay-to-smarthost) with AUTH = oauth: it reads
a SASL bearer token, fresh on every delivery, from a TOKEN_FILE; that OAuth
access token expires roughly hourly. Pepsi ships
pepsi-helper-token-refresh(1) to keep the file current, but that is
only one implementation of a contract — you can drop in your own refresher
(a cron job hitting a token endpoint, a cloud-provider agent, a secrets-manager
sidecar) as long as it honours the same contract. This section documents it so
you can.
18.9.1. Why a separate service, not a stage¶
Refreshing is not per-message work, it needs the provider’s client secret
(which the stage must never see), and it should keep running even when no mail is
flowing. So it is a standalone program that runs as its own, more-trusted
user, and it is deliberately not part of pepsi.target: a site may
already refresh tokens by other means. Enable it only when you want Pepsi to do
the refreshing.
18.9.2. The contract¶
Any refresher — Pepsi’s or your own — must satisfy four things:
Keep the client secrets out of the stage. Put the credential-source configuration in a separate file and pull it into
pepsi.confwith the taler@inline-secret@ <SECTION> <FILE>directive (see pepsi.conf(5)). Make that file readable only by the refresher’s user.@inline-secret@is special precisely here: a reader that cannot open the file (the stage, running aspepsi) silently skips the section and parses the rest of the configuration normally, whereas a plain@inline@would error. So the secret never reaches the stage, yet both programs share onepepsi.conf.Write the credential where the stage reads it, atomically. Replace the
TOKEN_FILEcontents with a write-to-temp-then-renameso the stage, which may read at any instant, never sees a partial file. The file content is exactly the credential the stage expects (for OAuth: the bare access token, whitespace-trimmed).Get the group and mode right. The token files live in a directory that is SGID the
pepsi-tokengroup (default/var/pepsi/tokens), so a new file inherits that group; write it mode0640. The consuming stage binary is installed SGIDpepsi-token, which is what lets the dispatcher’s unprivilegedpepsiworker read the token — without makingpepsia permanent member of the group. Any long-lived secret the refresher persists for itself (e.g. a rotated refresh token) belongs in a private directory (mode0700), never in the group-readable token directory.Fail safe. On a refresh error, leave the previous token file in place and retry with back-off; never truncate a working token because the endpoint was briefly unreachable. The relay stage already treats a missing/expired token as a transient delivery failure, so the message simply waits and retries once a fresh token lands.
18.9.3. Replacing the bundled refresher¶
To substitute your own implementation, keep the pepsi-helper-token-refresh
service disabled (it is shipped disabled) and have your program write the same
TOKEN_FILE with the same permissions. Nothing in the stage, ingress or
dispatcher changes — the stage only ever reads the file. pepsi-setup
prints a reminder whenever a smarthost uses AUTH = oauth so you do not forget
to wire a refresher up.
The same pattern generalises to any future stage that needs a periodically
rotated secret: give it a *_FILE option, read the file per use, put the
issuing credentials behind @inline-secret@, and run the issuer as its own
user writing through a group-scoped, SGID-stage-readable file.
18.9.4. Kerberos (AUTH = gssapi)¶
The smarthost relay’s AUTH = gssapi follows the same external-refresh
contract, but the rotated credential is a Kerberos ticket in a credential
cache rather than a token file, and the refresher is a standard tool rather
than a Pepsi binary — so Pepsi ships none. The contract is:
Acquire and renew the ticket out of band. Run
k5start/krenew(or akinit -kcron job) under a more-trusted user, authenticating from a keytab that the relay stage never reads. This obtains and periodically renews a ticket-granting ticket for Pepsi’s own principal.Write the cache where the stage reads it. Point
k5startat aFILE:credential cache under the SGIDpepsi-tokendirectory/var/pepsi/krb5(so the file inherits the group), and name it in the MTA’sKRB5CCNAMEoption (or setKRB5CCNAMEon the dispatcher service environment for the default cache). The SGID smarthost stage binary — already SGIDpepsi-tokenfor OAuth/mTLS — is what lets thepepsiworker read it.Fail safe. A missing or expired ticket makes the delivery a transient failure, so the message waits and retries once a valid ticket is in the cache.
pepsi-setup prints a reminder (and sanity-checks FILE: cache readability)
whenever a smarthost uses AUTH = gssapi.
18.10. Keeping the feature-stability table current¶
Every user-visible feature is listed in the single Feature stability
table. That page is generated by contrib/update-feature-stability.sh,
which merges two sources:
the hand-maintained registry
contrib/feature-registry.tsv— one row per feature, with itskey(the stable telemetry identifier), human-readable name, governing RFC, automated-test coverage (U/I/I/U/-), manual-test status (yes/no) and whether it is instrumented for telemetry (yes/no); anda
pepsi-telemetryGET /telemetry/report— the per-feature deployment and usage counts, joined to the registry onkey.
A feature whose telemetry column is no cannot be meaningfully counted.
Such a row renders with a (*) after its name and — in its Deployments,
Uses and Stability cells; it has no telemetry_update call. There are four
reasons a row is in that state, and the reason must be written down beside the
key in KNOWN_UNINSTRUMENTED (tests/feature_registry.rs) — a test checks
that the list and this column agree, so that “not measured yet” can be told apart
from “will never be measured”:
A passive protocol capability the client never signals its use of (
ENHANCEDSTATUSCODES). Note thatPIPELININGis not one of these: a client that sends a command without waiting for the reply is directly observable, and ingress reports it once per session.A universal facility whose count would measure process starts rather than use — structured logging, the telemetry channel itself,
pepsi-setup(every deployment runs it, so the count would duplicate the deployment count).A property of a configuration or a schema rather than an event: the
pepsi-cryptogrant on the private-key column, whose “use” is the absence of a permission error.A short-lived process: an operator CLI, or a privileged
pepsi-helper-*. This one is a hard limit of the producer API rather than a matter of taste, so it is worth stating precisely.
telemetry_update hands the feature name to a background task and returns; the
task then needs a turn of the Tokio runtime to connect to the local
pepsi-telemetry-client socket and write. A process that returns from its
block_on immediately after reporting is dropped before that turn happens, and
the event is lost — measured at 0 deliveries in 200 runs, against 30 out of 30
when the process stays in the runtime one millisecond longer
(pepsi-common/tests/telemetry_short_lived.rs pins this). So the threshold is
not “the process must live for a while” but “the runtime must turn over once
more”. A long-lived daemon or stage worker is unaffected; a CLI cannot report the
thing it has just finished doing, which is the only interesting moment it has.
Wiring the operator tools therefore needs a bounded, awaited flush() on the
producer API first — and a per-row notion of scale, because the stability tiers
are calibrated on messages (1 000 uses for used, 100 000 for stable) and a
command a human runs a dozen times a year would be pinned experimental for
ever by a counter that was working perfectly.
The programs that do install the sink, and so can report, are pepsi-ingress,
the stage workers (via run_worker), pepsi-dispatch, pepsi-httpd,
pepsi-keydisc, pepsi-failure-bouncer in its service mode, and
pepsi-setup once the wizard has written a configuration whose
SHARE_TELEMETRY answer it can read back. That last condition is not a
technicality: reporting anything from the interview before the operator has
answered would submit the activity of a deployment that never consented, which is
what opt-in exists to prevent.
Whenever you change the code or the tests, keep the table honest:
Adding a feature. Add a row to
contrib/feature-registry.tsv. Unless the feature is genuinely un-instrumentable (settelemetry=no— reserve this for the cases above, and add the key toKNOWN_UNINSTRUMENTEDintests/feature_registry.rswith the reason), settelemetry=yesand calltelemetry_update("<key>")in a good place in the code — somewhere the feature genuinely runs, so the count reflects real usage (and the “enabled” snapshot reflects deployments that actually use it). In a stage, usectx.telemetry_update("<key>")at the point the stage does its characteristic work; elsewhere callpepsi_common::telemetry::telemetry_update("<key>")directly (ingress does this forstarttls,8bitmime, the inbound auth checksspf/dkim-verify/dmarc/iprev/auth-results, and the relay client fordane,tlsrptandtls-identity). Thekeystring in the code and the registry must match — that is how the telemetry counts attach to the row, and the generator’sstderrwarning (below) catches drift.Writing a test. Update that feature’s
testcolumn:Uif you added a unit / in-processcargo test,Ifor atests/*.shlive-pipeline script or apepsi-test-stagesend-to-end test,I/Ufor both.Manually testing. Set the feature’s
manualcolumn toyes.Refreshing the counts. Run
contrib/update-feature-stability.sh(it fetchesTELEMETRY_URLor reads aTELEMETRY_REPORT_FILE; with neither, every count is0and every feature isexperimental) and commit the regenerateddocs/manual/feature-stability.rst. The script warns onstderrabout any telemetry feature with no registry row, so a stray or renamedkeyis caught.
Do not edit docs/manual/feature-stability.rst by hand — it is
overwritten on every regeneration.
cargo test --test feature_registry enforces all of the above without needing
a telemetry deployment: every reported key has a row, every row is reachable from
the sources, no key is on KNOWN_UNINSTRUMENTED while the code reports it, and
the list and the telemetry column say the same thing.