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}