dwara_core/plugins/
chain.rs

1//! The unified plugin dispatch chain (DW-119).
2//!
3//! [`PluginChain`] is the single integration seam the dataplane calls.
4//! Given a route's plugin names, the gateway's plugin configs, the
5//! native registry, and an optional WASM dispatch adapter, it builds
6//! the per-request execution list combining native filters and WASM
7//! instances IN PHASE ORDER (deterministic, using the same ordering
8//! logic as `wasm::lifecycle::PluginLifecycle::phase_order`). It
9//! exposes the same phase methods as the WASM runner
10//! (`on_request_headers`, `on_request_body`, `on_response_headers`,
11//! `on_response_body`) and dispatches to each plugin in order, threading
12//! headers/body through and short-circuiting on `LocalResponse`/`Error`.
13//!
14//! ## Why a generic WasmDispatch adapter
15//!
16//! `plugins` must not depend on `wasm` (that would be an upward import
17//! once `wasm` depends on `plugins` for the adapter). Instead, the
18//! chain is generic over a [`WasmDispatch`] trait -- a minimal
19//! per-request interface the `wasm` domain implements to bridge its
20//! `PluginInstances` into the unified chain. The proxy-wasm host
21//! always compiles (DW-157); [`NoWasm`] serves native-only chains and
22//! tests.
23//!
24//! ## Attachment semantics equivalence
25//!
26//! A native filter and a WASM plugin occupy the same phase slot on the
27//! same route, selected by config. The chain orders them by their
28//! position in the route's `plugins` list within each phase (the same
29//! deterministic order `phase_order` produces), so there is no
30//! dataplane-visible difference in attachment semantics -- only the
31//! implementation differs (compiled-in vs sandboxed).
32
33use std::collections::HashMap;
34
35use crate::config::{PluginConfig, PluginPhase};
36
37use super::filter::{FilterOutcome, LocalResponse, NativeFilter};
38use super::registry::NativeRegistry;
39
40/// The per-request outcome of a unified chain phase, mirroring
41/// `wasm::runner::PhaseOutcome` so the dataplane treats native and WASM
42/// plugins identically.
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub enum ChainOutcome {
45    /// All plugins in the phase returned Continue. The request proceeds
46    /// normally; the (possibly modified) headers/body are returned.
47    Continue,
48    /// A plugin short-circuited with a local response. The proxy should
49    /// return this response immediately.
50    LocalResponse(LocalResponse),
51    /// A plugin errored (native filter error or WASM trap). The proxy
52    /// should return a 500. `plugin` names the failing entry so the
53    /// dataplane can attribute the failure in logs and metrics
54    /// (`dwara_plugin_failures_total{name,reason}`, DW-157).
55    Error { plugin: String, message: String },
56    /// A WASM plugin registered HTTP callouts and paused (DW-167).
57    /// Native filters never produce this. The caller (the async
58    /// plugin_dispatch boundary) performs the callouts through the
59    /// adapter, delivers the responses, and then continues the phase
60    /// with the matching `resume_*` method — entries BEFORE (and the
61    /// paused one itself) do not run again.
62    CalloutPending,
63}
64
65/// A minimal per-request WASM dispatch interface the `wasm` domain
66/// implements to bridge its `PluginInstances` into the unified chain.
67///
68/// The chain calls these methods on the adapter for each WASM plugin in
69/// the phase, in order, passing the plugin's NAME: the adapter holds
70/// the per-request WASM instances keyed by name and dispatches to
71/// exactly that instance (so a chain interleaving native filters and
72/// WASM plugins in one phase runs each entry exactly once). [`NoWasm`]
73/// is a no-op adapter that always returns [`ChainOutcome::Continue`].
74pub trait WasmDispatch {
75    /// Run `on_request_headers` for the named WASM plugin. Returns the
76    /// outcome and the (possibly modified) headers.
77    fn on_request_headers(
78        &mut self,
79        name: &str,
80        headers: Vec<(String, String)>,
81    ) -> (ChainOutcome, Vec<(String, String)>);
82
83    /// Run `on_request_body` for the named WASM plugin. Returns the
84    /// outcome and the (possibly modified) body.
85    fn on_request_body(&mut self, name: &str, body: Vec<u8>) -> (ChainOutcome, Vec<u8>);
86
87    /// Run `on_response_headers` for the named WASM plugin. Returns the
88    /// outcome and the (possibly modified) headers.
89    fn on_response_headers(
90        &mut self,
91        name: &str,
92        headers: Vec<(String, String)>,
93    ) -> (ChainOutcome, Vec<(String, String)>);
94
95    /// Run `on_response_body` for the named WASM plugin. Returns the
96    /// outcome and the (possibly modified) body.
97    fn on_response_body(&mut self, name: &str, body: Vec<u8>) -> (ChainOutcome, Vec<u8>);
98
99    /// Call `on_done` for all WASM instances (the cleanup path).
100    fn on_done(&mut self) {}
101}
102
103/// A no-op WASM dispatch adapter for native-only chains (and tests).
104/// Every method returns [`ChainOutcome::Continue`] with the input
105/// unchanged.
106#[derive(Default)]
107pub struct NoWasm;
108
109impl WasmDispatch for NoWasm {
110    fn on_request_headers(
111        &mut self,
112        _name: &str,
113        headers: Vec<(String, String)>,
114    ) -> (ChainOutcome, Vec<(String, String)>) {
115        (ChainOutcome::Continue, headers)
116    }
117
118    fn on_request_body(&mut self, _name: &str, body: Vec<u8>) -> (ChainOutcome, Vec<u8>) {
119        (ChainOutcome::Continue, body)
120    }
121
122    fn on_response_headers(
123        &mut self,
124        _name: &str,
125        headers: Vec<(String, String)>,
126    ) -> (ChainOutcome, Vec<(String, String)>) {
127        (ChainOutcome::Continue, headers)
128    }
129
130    fn on_response_body(&mut self, _name: &str, body: Vec<u8>) -> (ChainOutcome, Vec<u8>) {
131        (ChainOutcome::Continue, body)
132    }
133}
134
135/// One entry in the per-request execution list: either a native filter
136/// (with its plugin name, for failure attribution) or a reference to a
137/// WASM plugin by name (dispatched via the [`WasmDispatch`] adapter).
138enum ChainEntry {
139    Native(String, Box<dyn NativeFilter>),
140    Wasm(String),
141}
142
143/// A native filter whose factory errored while the chain was being
144/// built (DW-157). The configured filter could not be constructed, so
145/// it NEVER runs; the chain reports the failure instead of silently
146/// skipping the entry, and the caller must fail closed (a request the
147/// gate admitted must not proceed without a plugin its route
148/// configures).
149#[derive(Clone, Debug, PartialEq, Eq)]
150pub struct NativeCreateFailure {
151    /// The config-declared plugin name (failure attribution).
152    pub plugin: String,
153    /// The factory's error message.
154    pub message: String,
155}
156
157/// The fail-closed outcome for a `resume_*` call whose recorded pause
158/// does not match the resumed phase (or records no pause at all):
159/// unreachable with today's driver (every `CalloutPending` sets the
160/// record; every matching resume consumes it), and silently falling
161/// back to `start = 0` would RE-RUN the phase — plugins before the
162/// pause would execute twice. A driver bug fails closed instead.
163fn callout_resume_mismatch(phase: &str) -> ChainOutcome {
164    ChainOutcome::Error {
165        plugin: "plugin_chain".to_string(),
166        message: format!(
167            "callout resume on {phase} without a matching recorded pause; failing closed"
168        ),
169    }
170}
171
172/// The unified per-request plugin chain.
173///
174/// Built from a route's plugin names + the gateway's plugin configs +
175/// the native registry + a WASM dispatch adapter. Holds the native
176/// filter instances for this request and the names of the WASM plugins
177/// (the adapter owns the WASM instances). Dispatches each phase to the
178/// plugins in deterministic phase order, short-circuiting on
179/// `LocalResponse`/`Error`.
180pub struct PluginChain<W: WasmDispatch = NoWasm> {
181    /// The plugins active in each phase, in route-declaration order
182    /// within the phase (deterministic, matching `phase_order`).
183    phases: HashMap<PluginPhase, Vec<ChainEntry>>,
184    /// The WASM dispatch adapter (owns per-request WASM instances).
185    wasm: W,
186    /// The phase-list index of the WASM entry that paused for callouts
187    /// (DW-167), set when a phase drive returns
188    /// [`ChainOutcome::CalloutPending`] and consumed by the matching
189    /// `resume_*` method (which continues from the entry AFTER it).
190    paused_entry: Option<(PluginPhase, usize)>,
191}
192
193impl<W: WasmDispatch> PluginChain<W> {
194    /// Build a chain for a route's plugin names.
195    ///
196    /// `plugin_names` is the route's `plugins` list (declaration order).
197    /// `configs` is the gateway's top-level `plugins` list keyed by
198    /// name. `registry` provides native filter factories. `wasm` is the
199    /// per-request WASM dispatch adapter (use [`NoWasm`] for native-only
200    /// chains).
201    ///
202    /// Returns the chain and the list of native entries whose factory
203    /// ERRORED ([`NativeCreateFailure`]) — fail-closed material (DW-157):
204    /// the configured filter never runs, so the caller must answer the
205    /// request with the fail-closed 500 rather than proceed without it;
206    /// the chain deliberately does NOT silently skip a reported entry.
207    /// A plugin name that is neither a native filter in the registry nor
208    /// a WASM plugin in `configs` is silently skipped (it was already
209    /// flagged by validation as an unknown reference, and the
210    /// dataplane's health gate fails closed on it before the chain is
211    /// built).
212    pub fn new(
213        plugin_names: &[String],
214        configs: &HashMap<String, PluginConfig>,
215        registry: &NativeRegistry,
216        wasm: W,
217    ) -> (Self, Vec<NativeCreateFailure>) {
218        // The deterministic phase order: for each phase, the plugins
219        // that declare it, in route-declaration order. This matches
220        // wasm::lifecycle::PluginLifecycle::phase_order exactly.
221        let phase_list = [
222            PluginPhase::RequestHeaders,
223            PluginPhase::RequestBody,
224            PluginPhase::ResponseHeaders,
225            PluginPhase::ResponseBody,
226        ];
227
228        let mut phases: HashMap<PluginPhase, Vec<ChainEntry>> = HashMap::new();
229        let mut create_failures = Vec::new();
230        for phase in &phase_list {
231            let mut entries = Vec::new();
232            for name in plugin_names {
233                let Some(config) = configs.get(name) else {
234                    continue;
235                };
236                if !config.phases.contains(phase) {
237                    continue;
238                }
239                if let Some(native_name) = &config.native {
240                    match registry.create(native_name, &config.config) {
241                        Ok(filter) => entries.push(ChainEntry::Native(name.clone(), filter)),
242                        Err(e) => create_failures.push(NativeCreateFailure {
243                            plugin: name.clone(),
244                            message: e.to_string(),
245                        }),
246                    }
247                } else if config.wasm.is_some() || config.source.is_some() {
248                    // DW-165: a registry `source:` plugin is a WASM
249                    // plugin whose artifact resolved to a verified
250                    // local file; it occupies the same phase slot.
251                    entries.push(ChainEntry::Wasm(name.clone()));
252                }
253            }
254            if !entries.is_empty() {
255                phases.insert(*phase, entries);
256            }
257        }
258
259        (
260            Self {
261                phases,
262                wasm,
263                paused_entry: None,
264            },
265            create_failures,
266        )
267    }
268
269    /// Whether the chain has any plugins at all.
270    pub fn is_empty(&self) -> bool {
271        self.phases.is_empty()
272    }
273
274    /// Whether at least one plugin declares the given phase. The
275    /// dataplane uses this to decide whether a body phase requires
276    /// buffering (DW-157): a route whose plugins declare no body phase
277    /// keeps its zero-buffering streaming path.
278    pub fn has_phase(&self, phase: PluginPhase) -> bool {
279        self.phases.contains_key(&phase)
280    }
281
282    /// Run the `request_headers` phase across all plugins in order.
283    /// Returns the outcome and the (possibly modified) headers.
284    pub fn on_request_headers(
285        &mut self,
286        headers: Vec<(String, String)>,
287    ) -> (ChainOutcome, Vec<(String, String)>) {
288        self.request_headers_from(headers, 0)
289    }
290
291    /// Continue the `request_headers` phase AFTER the paused entry
292    /// (DW-167): `headers` is the resumed plugin's post-callout
293    /// output. Plugins before (and including) the paused entry do not
294    /// run again.
295    pub fn resume_request_headers(
296        &mut self,
297        headers: Vec<(String, String)>,
298    ) -> (ChainOutcome, Vec<(String, String)>) {
299        let start = match self.paused_entry.take() {
300            Some((PluginPhase::RequestHeaders, idx)) => idx + 1,
301            _ => return (callout_resume_mismatch("request_headers"), headers),
302        };
303        self.request_headers_from(headers, start)
304    }
305
306    fn request_headers_from(
307        &mut self,
308        headers: Vec<(String, String)>,
309        start: usize,
310    ) -> (ChainOutcome, Vec<(String, String)>) {
311        let Some(entries) = self.phases.get_mut(&PluginPhase::RequestHeaders) else {
312            return (ChainOutcome::Continue, headers);
313        };
314        let mut current = headers;
315        for (idx, entry) in entries.iter_mut().enumerate().skip(start) {
316            match entry {
317                ChainEntry::Native(name, filter) => {
318                    match filter.on_request_headers(current.clone()) {
319                        FilterOutcome::Continue { headers, .. } => current = headers,
320                        FilterOutcome::LocalResponse(resp) => {
321                            return (ChainOutcome::LocalResponse(resp), current);
322                        }
323                        FilterOutcome::Error(e) => {
324                            return (
325                                ChainOutcome::Error {
326                                    plugin: name.clone(),
327                                    message: e,
328                                },
329                                current,
330                            );
331                        }
332                    }
333                }
334                ChainEntry::Wasm(name) => {
335                    let (outcome, h) = self.wasm.on_request_headers(name.as_str(), current);
336                    match outcome {
337                        ChainOutcome::Continue => current = h,
338                        ChainOutcome::LocalResponse(resp) => {
339                            return (ChainOutcome::LocalResponse(resp), h);
340                        }
341                        ChainOutcome::Error { .. } => return (outcome, h),
342                        ChainOutcome::CalloutPending => {
343                            self.paused_entry = Some((PluginPhase::RequestHeaders, idx));
344                            return (ChainOutcome::CalloutPending, h);
345                        }
346                    }
347                }
348            }
349        }
350        (ChainOutcome::Continue, current)
351    }
352
353    /// Run the `request_body` phase across all plugins in order.
354    /// Returns the outcome and the (possibly modified) body.
355    pub fn on_request_body(&mut self, body: Vec<u8>) -> (ChainOutcome, Vec<u8>) {
356        self.request_body_from(body, 0)
357    }
358
359    /// Continue the `request_body` phase AFTER the paused entry
360    /// (DW-167): `body` is the resumed plugin's post-callout output.
361    pub fn resume_request_body(&mut self, body: Vec<u8>) -> (ChainOutcome, Vec<u8>) {
362        let start = match self.paused_entry.take() {
363            Some((PluginPhase::RequestBody, idx)) => idx + 1,
364            _ => return (callout_resume_mismatch("request_body"), body),
365        };
366        self.request_body_from(body, start)
367    }
368
369    fn request_body_from(&mut self, body: Vec<u8>, start: usize) -> (ChainOutcome, Vec<u8>) {
370        let Some(entries) = self.phases.get_mut(&PluginPhase::RequestBody) else {
371            return (ChainOutcome::Continue, body);
372        };
373        let mut current = body;
374        for (idx, entry) in entries.iter_mut().enumerate().skip(start) {
375            match entry {
376                ChainEntry::Native(name, filter) => match filter.on_request_body(current.clone()) {
377                    FilterOutcome::Continue { body, .. } => current = body,
378                    FilterOutcome::LocalResponse(resp) => {
379                        return (ChainOutcome::LocalResponse(resp), current);
380                    }
381                    FilterOutcome::Error(e) => {
382                        return (
383                            ChainOutcome::Error {
384                                plugin: name.clone(),
385                                message: e,
386                            },
387                            current,
388                        );
389                    }
390                },
391                ChainEntry::Wasm(name) => {
392                    let (outcome, b) = self.wasm.on_request_body(name.as_str(), current);
393                    match outcome {
394                        ChainOutcome::Continue => current = b,
395                        ChainOutcome::LocalResponse(resp) => {
396                            return (ChainOutcome::LocalResponse(resp), b);
397                        }
398                        ChainOutcome::Error { .. } => return (outcome, b),
399                        ChainOutcome::CalloutPending => {
400                            self.paused_entry = Some((PluginPhase::RequestBody, idx));
401                            return (ChainOutcome::CalloutPending, b);
402                        }
403                    }
404                }
405            }
406        }
407        (ChainOutcome::Continue, current)
408    }
409
410    /// Run the `response_headers` phase across all plugins in order.
411    /// Returns the outcome and the (possibly modified) headers.
412    pub fn on_response_headers(
413        &mut self,
414        headers: Vec<(String, String)>,
415    ) -> (ChainOutcome, Vec<(String, String)>) {
416        self.response_headers_from(headers, 0)
417    }
418
419    /// Continue the `response_headers` phase AFTER the paused entry
420    /// (DW-167): `headers` is the resumed plugin's post-callout
421    /// output.
422    pub fn resume_response_headers(
423        &mut self,
424        headers: Vec<(String, String)>,
425    ) -> (ChainOutcome, Vec<(String, String)>) {
426        let start = match self.paused_entry.take() {
427            Some((PluginPhase::ResponseHeaders, idx)) => idx + 1,
428            _ => return (callout_resume_mismatch("response_headers"), headers),
429        };
430        self.response_headers_from(headers, start)
431    }
432
433    fn response_headers_from(
434        &mut self,
435        headers: Vec<(String, String)>,
436        start: usize,
437    ) -> (ChainOutcome, Vec<(String, String)>) {
438        let Some(entries) = self.phases.get_mut(&PluginPhase::ResponseHeaders) else {
439            return (ChainOutcome::Continue, headers);
440        };
441        let mut current = headers;
442        for (idx, entry) in entries.iter_mut().enumerate().skip(start) {
443            match entry {
444                ChainEntry::Native(name, filter) => {
445                    match filter.on_response_headers(current.clone()) {
446                        FilterOutcome::Continue { headers, .. } => current = headers,
447                        FilterOutcome::LocalResponse(resp) => {
448                            return (ChainOutcome::LocalResponse(resp), current);
449                        }
450                        FilterOutcome::Error(e) => {
451                            return (
452                                ChainOutcome::Error {
453                                    plugin: name.clone(),
454                                    message: e,
455                                },
456                                current,
457                            );
458                        }
459                    }
460                }
461                ChainEntry::Wasm(name) => {
462                    let (outcome, h) = self.wasm.on_response_headers(name.as_str(), current);
463                    match outcome {
464                        ChainOutcome::Continue => current = h,
465                        ChainOutcome::LocalResponse(resp) => {
466                            return (ChainOutcome::LocalResponse(resp), h);
467                        }
468                        ChainOutcome::Error { .. } => return (outcome, h),
469                        ChainOutcome::CalloutPending => {
470                            self.paused_entry = Some((PluginPhase::ResponseHeaders, idx));
471                            return (ChainOutcome::CalloutPending, h);
472                        }
473                    }
474                }
475            }
476        }
477        (ChainOutcome::Continue, current)
478    }
479
480    /// Run the `response_body` phase across all plugins in order.
481    /// Returns the outcome and the (possibly modified) body.
482    pub fn on_response_body(&mut self, body: Vec<u8>) -> (ChainOutcome, Vec<u8>) {
483        self.response_body_from(body, 0)
484    }
485
486    /// Continue the `response_body` phase AFTER the paused entry
487    /// (DW-167): `body` is the resumed plugin's post-callout output.
488    pub fn resume_response_body(&mut self, body: Vec<u8>) -> (ChainOutcome, Vec<u8>) {
489        let start = match self.paused_entry.take() {
490            Some((PluginPhase::ResponseBody, idx)) => idx + 1,
491            _ => return (callout_resume_mismatch("response_body"), body),
492        };
493        self.response_body_from(body, start)
494    }
495
496    fn response_body_from(&mut self, body: Vec<u8>, start: usize) -> (ChainOutcome, Vec<u8>) {
497        let Some(entries) = self.phases.get_mut(&PluginPhase::ResponseBody) else {
498            return (ChainOutcome::Continue, body);
499        };
500        let mut current = body;
501        for (idx, entry) in entries.iter_mut().enumerate().skip(start) {
502            match entry {
503                ChainEntry::Native(name, filter) => {
504                    match filter.on_response_body(current.clone()) {
505                        FilterOutcome::Continue { body, .. } => current = body,
506                        FilterOutcome::LocalResponse(resp) => {
507                            return (ChainOutcome::LocalResponse(resp), current);
508                        }
509                        FilterOutcome::Error(e) => {
510                            return (
511                                ChainOutcome::Error {
512                                    plugin: name.clone(),
513                                    message: e,
514                                },
515                                current,
516                            );
517                        }
518                    }
519                }
520                ChainEntry::Wasm(name) => {
521                    let (outcome, b) = self.wasm.on_response_body(name.as_str(), current);
522                    match outcome {
523                        ChainOutcome::Continue => current = b,
524                        ChainOutcome::LocalResponse(resp) => {
525                            return (ChainOutcome::LocalResponse(resp), b);
526                        }
527                        ChainOutcome::Error { .. } => return (outcome, b),
528                        ChainOutcome::CalloutPending => {
529                            self.paused_entry = Some((PluginPhase::ResponseBody, idx));
530                            return (ChainOutcome::CalloutPending, b);
531                        }
532                    }
533                }
534            }
535        }
536        (ChainOutcome::Continue, current)
537    }
538
539    /// Call `on_done` on the WASM adapter (the cleanup path). Native
540    /// filters have no explicit done callback; they are dropped when the
541    /// chain is dropped.
542    pub fn on_done(&mut self) {
543        self.wasm.on_done();
544    }
545
546    /// Borrow the WASM adapter (for callers that need to drive it
547    /// directly, e.g. to instantiate per-request WASM instances before
548    /// the chain runs).
549    pub fn wasm(&self) -> &W {
550        &self.wasm
551    }
552
553    /// Mutably borrow the WASM adapter.
554    pub fn wasm_mut(&mut self) -> &mut W {
555        &mut self.wasm
556    }
557}