praxis_filter/pipeline/build.rs
1// SPDX-License-Identifier: MIT
2// Copyright (c) 2024 Praxis Contributors
3
4//! Pipeline construction and ordering diagnostics.
5
6use std::{collections::HashMap, mem, sync::Arc};
7
8use praxis_core::{config::FilterEntry, id::IdGenerator, time::SystemTimeSource};
9use tracing::{debug, warn};
10
11use super::{FilterPipeline, body::compute_body_capabilities, filter::PipelineFilter};
12use crate::{FilterError, any_filter::AnyFilter, registry::FilterRegistry};
13
14// -----------------------------------------------------------------------------
15// FilterPipeline Factory
16// -----------------------------------------------------------------------------
17
18impl FilterPipeline {
19 /// Build a pipeline by instantiating each filter entry via the registry.
20 ///
21 /// Conditions are moved out of entries via [`mem::take`] to avoid
22 /// cloning. After this call, each entry's condition vecs are empty.
23 ///
24 /// # Errors
25 ///
26 /// Returns [`FilterError`] if any filter fails to instantiate.
27 #[expect(clippy::too_many_lines, reason = "pipeline construction is inherently sequential")]
28 pub fn build(entries: &mut [FilterEntry], registry: &FilterRegistry) -> Result<Self, FilterError> {
29 let mut filters = Vec::with_capacity(entries.len());
30 for (filter_id, entry) in entries.iter_mut().enumerate() {
31 let filter = registry.create(&entry.filter_type, &entry.config)?;
32 warn_tcp_unsupported_fields(&filter, entry);
33 let has_conditions = !entry.conditions.is_empty() || !entry.response_conditions.is_empty();
34 debug!(
35 filter = filter.name(),
36 conditions = has_conditions,
37 "filter added to pipeline"
38 );
39 let mut pf = PipelineFilter::new(
40 filter_id,
41 filter,
42 mem::take(&mut entry.conditions),
43 mem::take(&mut entry.response_conditions),
44 );
45 pf.failure_mode = entry.failure_mode;
46 pf.name = entry.name.as_ref().map(|n| Arc::from(n.as_str()));
47 filters.push(pf);
48 }
49 let body_capabilities = compute_body_capabilities(&filters);
50 let compression = extract_compression_config(&filters);
51
52 Ok(Self {
53 body_capabilities,
54 compression,
55 filters,
56 health_registry: None,
57 id_generator: Arc::new(IdGenerator::new()),
58 kv_stores: None,
59 pipeline_extensions: Vec::new(),
60 time_source: Arc::new(SystemTimeSource),
61 })
62 }
63
64 /// Build a pipeline with branch chain resolution.
65 ///
66 /// Like [`build`], but also resolves `branch_chains` on each
67 /// filter entry into runtime `ResolvedBranch` types using
68 /// the provided chain lookup table.
69 ///
70 /// # Errors
71 ///
72 /// Returns [`FilterError`] if any filter fails to instantiate
73 /// or any branch chain reference is unresolvable.
74 ///
75 /// [`build`]: FilterPipeline::build
76 pub fn build_with_chains(
77 entries: &mut [FilterEntry],
78 registry: &FilterRegistry,
79 chains: &HashMap<&str, &[FilterEntry]>,
80 ) -> Result<Self, FilterError> {
81 let filters = super::build_branch::resolve_chain_filters(entries, registry, chains, 0)?;
82 let body_capabilities = compute_body_capabilities(&filters);
83 let compression = extract_compression_config(&filters);
84 Ok(Self {
85 body_capabilities,
86 compression,
87 filters,
88 health_registry: None,
89 id_generator: Arc::new(IdGenerator::new()),
90 kv_stores: None,
91 pipeline_extensions: Vec::new(),
92 time_source: Arc::new(SystemTimeSource),
93 })
94 }
95
96 /// Validate the pipeline for structural misconfigurations that
97 /// would cause runtime failures (502s, unreachable filters,
98 /// cluster mismatches).
99 ///
100 /// ```
101 /// use praxis_filter::{FailureMode, FilterEntry, FilterPipeline, FilterRegistry};
102 ///
103 /// let registry = FilterRegistry::with_builtins();
104 /// let mut entries = vec![FilterEntry {
105 /// branch_chains: None,
106 /// filter_type: "load_balancer".into(),
107 /// config: serde_yaml::from_str("clusters: []").unwrap(),
108 /// conditions: vec![],
109 /// name: None,
110 /// response_conditions: vec![],
111 /// failure_mode: FailureMode::default(),
112 /// }];
113 /// let pipeline = FilterPipeline::build(&mut entries, ®istry).unwrap();
114 /// let errors = pipeline.ordering_errors(&entries, false);
115 /// assert!(
116 /// errors
117 /// .iter()
118 /// .any(|e| e.contains("without a preceding router"))
119 /// );
120 /// ```
121 ///
122 /// [`build`]: FilterPipeline::build
123 pub fn ordering_errors(&self, entries: &[FilterEntry], allow_open_security: bool) -> Vec<String> {
124 let names: Vec<&str> = self.filters.iter().map(|pf| pf.filter.name()).collect();
125
126 let mut errors = Vec::new();
127
128 super::checks::check_lb_without_cluster_selector(&names, &mut errors);
129 super::checks::check_unconditional_static_response(&names, &self.filters, &mut errors);
130 super::checks::check_conditional_security(&names, &self.filters, &mut errors);
131 super::checks::check_open_security_filters(&names, &self.filters, allow_open_security, &mut errors);
132 super::checks::check_duplicate_routers(&names, &mut errors);
133 super::checks::check_duplicate_load_balancers(&names, &mut errors);
134 super::checks::check_misaligned_clusters(entries, &mut errors);
135 super::checks::check_duplicate_rewrite_filters(&names, entries, &mut errors);
136
137 errors
138 }
139
140 /// Check for non-fatal ordering advisories.
141 ///
142 /// Currently detects: all routers conditional with no fallback.
143 ///
144 /// ```
145 /// use praxis_filter::{FailureMode, FilterEntry, FilterPipeline, FilterRegistry};
146 ///
147 /// let registry = FilterRegistry::with_builtins();
148 /// let mut entries = vec![FilterEntry {
149 /// branch_chains: None,
150 /// filter_type: "router".into(),
151 /// config: serde_yaml::from_str("routes: []").unwrap(),
152 /// conditions: vec![praxis_core::config::Condition::When(
153 /// praxis_core::config::ConditionMatch {
154 /// path: None,
155 /// path_prefix: Some("/api".to_owned()),
156 /// methods: None,
157 /// headers: None,
158 /// },
159 /// )],
160 /// name: None,
161 /// response_conditions: vec![],
162 /// failure_mode: FailureMode::default(),
163 /// }];
164 /// let pipeline = FilterPipeline::build(&mut entries, ®istry).unwrap();
165 /// let warnings = pipeline.ordering_warnings();
166 /// assert!(
167 /// warnings
168 /// .iter()
169 /// .any(|w| w.contains("all router filters are conditional"))
170 /// );
171 /// ```
172 pub fn ordering_warnings(&self) -> Vec<String> {
173 let names: Vec<&str> = self.filters.iter().map(|pf| pf.filter.name()).collect();
174
175 let mut warnings = Vec::new();
176
177 super::checks::check_router_without_lb(&names, &mut warnings);
178 super::checks::check_all_routers_conditional(&names, &self.filters, &mut warnings);
179
180 warnings
181 }
182}
183
184// -----------------------------------------------------------------------------
185// Utility Functions
186// -----------------------------------------------------------------------------
187
188/// Warn when a TCP filter has conditions or branch chains configured.
189///
190/// TCP filters do not support conditions or branching; these fields
191/// are silently ignored at runtime. Logging at build time helps
192/// operators catch misconfigurations.
193fn warn_tcp_unsupported_fields(filter: &AnyFilter, entry: &FilterEntry) {
194 if !matches!(filter, AnyFilter::Tcp(_)) {
195 return;
196 }
197 if !entry.conditions.is_empty() || !entry.response_conditions.is_empty() {
198 warn!(
199 filter = filter.name(),
200 "TCP filter has conditions that will be ignored; \
201 conditions are only evaluated for HTTP filters"
202 );
203 }
204 if entry.branch_chains.is_some() {
205 warn!(
206 filter = filter.name(),
207 "TCP filter has branch_chains that will be ignored; \
208 branching is only supported for HTTP filters"
209 );
210 }
211}
212
213/// Scan the filter list for a compression filter and extract its config.
214fn extract_compression_config(
215 filters: &[PipelineFilter],
216) -> Option<crate::builtins::http::payload_processing::compression_config::CompressionConfig> {
217 for pf in filters {
218 if let AnyFilter::Http(f) = &pf.filter
219 && let Some(cfg) = f.compression_config()
220 {
221 return Some(cfg.clone());
222 }
223 }
224 None
225}