1use std::sync::Arc;
6use tokio::sync::RwLock;
7
8use crate::config::{resolve_plugin_configs, GatewayConfig, RouteConfig, SystemConfig};
9use crate::config_store::ConfigStore;
10use crate::debug::DebugState;
11use crate::graph::{
12 compile_policy, expand_policy, validate_policy, validate_supernode, CompiledGraph,
13};
14use crate::metrics::GatewayMetrics;
15use crate::plugins::resources::PluginResources;
16
17type CompiledRoutes = Vec<(RouteConfig, Arc<CompiledGraph>)>;
19
20pub struct SharedState {
29 #[allow(dead_code)] pub system: SystemConfig,
32 pub gateway: RwLock<GatewayConfig>,
34 pub routes: RwLock<Vec<(RouteConfig, Arc<CompiledGraph>)>>,
37 pub config_path: Option<std::path::PathBuf>,
39 pub metrics: Arc<GatewayMetrics>,
43 pub resources: Arc<PluginResources>,
46 pub config_store: Arc<dyn ConfigStore>,
49 pub debug: Arc<DebugState>,
53 pub acme: arc_swap::ArcSwapOption<crate::acme::AcmeRuntime>,
57 pub acme_expected: bool,
64}
65
66impl SharedState {
67 pub fn new(
72 system: SystemConfig,
73 gateway: GatewayConfig,
74 config_path: Option<std::path::PathBuf>,
75 config_store: Arc<dyn ConfigStore>,
76 ) -> Result<Self, String> {
77 let metrics = Arc::new(GatewayMetrics::new());
78 let acme_expected = system.acme.is_some()
79 && system
80 .tls
81 .as_ref()
82 .is_some_and(|t| !t.managed_domains().is_empty());
83 let resources = PluginResources::new(Some(metrics.clone()));
84 resources
85 .consumers
86 .store(Arc::new(crate::consumers::ConsumerStore::from_config(
87 &gateway.consumers,
88 )?));
89 let routes = Self::compile_routes(&gateway, &resources)?;
90 let debug_state = Arc::new(DebugState::new(&system.debug));
91 if debug_state.enabled {
92 let bodies = if debug_state.capture_bodies {
93 "captured"
94 } else {
95 "excluded"
96 };
97 tracing::warn!(
98 "debug mode is ENABLED: policy traces capture request headers and \
99 context state into memory (bodies: {}). Do not enable in production.",
100 bodies
101 );
102 if debug_state.trace_all {
103 let header = debug_state.trigger_header.clone();
104 tracing::warn!(
105 "debug.trace_all is on: EVERY request is traced, not just those \
106 carrying '{}'. This snapshots the context once per node for all traffic.",
107 header
108 );
109 }
110 }
111 Ok(Self {
112 system,
113 gateway: RwLock::new(gateway),
114 routes: RwLock::new(routes),
115 config_path,
116 metrics,
117 resources,
118 config_store,
119 debug: debug_state,
120 acme: arc_swap::ArcSwapOption::empty(),
121 acme_expected,
122 })
123 }
124
125 pub async fn apply_gateway(&self, new_gw: GatewayConfig) -> Result<(), String> {
133 let (consumers, routes) = match Self::build_candidate(&new_gw, &self.resources) {
134 Ok(built) => built,
135 Err(e) => {
136 tracing::warn!("Rejected config: {}", e);
140 return Err(e);
141 }
142 };
143 tracing::info!(
144 "Applied config: {} routes from {} policies",
145 routes.len(),
146 new_gw.policies.len()
147 );
148 self.resources.consumers.store(Arc::new(consumers));
149 let mut gw = self.gateway.write().await;
150 *gw = new_gw;
151 let mut r = self.routes.write().await;
152 *r = routes;
153 Ok(())
154 }
155
156 fn build_candidate(
159 gw: &GatewayConfig,
160 resources: &Arc<PluginResources>,
161 ) -> Result<(crate::consumers::ConsumerStore, CompiledRoutes), String> {
162 let consumers = crate::consumers::ConsumerStore::from_config(&gw.consumers)?;
163 let routes = Self::compile_routes(gw, resources)?;
164 Ok((consumers, routes))
165 }
166
167 pub fn validate_gateway(&self, gw: &GatewayConfig) -> Result<(), String> {
177 crate::consumers::ConsumerStore::from_config(&gw.consumers)?;
178 Self::compile_routes(gw, &self.resources)?;
179 Ok(())
180 }
181
182 pub fn validate_gateway_dry(&self, gw: &GatewayConfig) -> Result<(), String> {
197 let prev = self.resources.stores.load_full();
198 let result = self.validate_gateway(gw);
199 self.resources.stores.store(prev);
200 result
201 }
202
203 pub async fn reload_from_disk(&self) -> Result<(), String> {
210 let new_gw = self.load_gateway_from_disk()?;
211 self.apply_gateway(new_gw).await
212 }
213
214 pub fn load_gateway_from_disk(&self) -> Result<GatewayConfig, String> {
218 let path = self
219 .config_path
220 .as_ref()
221 .ok_or("No config path set for hot-reload")?;
222 crate::config::load_yaml(path).map_err(|e| e.to_string())
223 }
224
225 fn compile_routes(
229 gateway: &GatewayConfig,
230 resources: &Arc<PluginResources>,
231 ) -> Result<CompiledRoutes, String> {
232 crate::stores::validate_stores(&gateway.stores)?;
233 let prev = resources.stores.load_full();
239 let candidate = crate::stores::StoreRegistry::rebuild(
240 &prev,
241 &gateway.stores,
242 resources.metrics.clone(),
243 )?;
244 resources.stores.store(Arc::new(candidate));
245 let result = Self::compile_routes_inner(gateway, resources);
246 if result.is_err() {
247 resources.stores.store(prev);
248 }
249 result
250 }
251
252 fn compile_routes_inner(
254 gateway: &GatewayConfig,
255 resources: &Arc<PluginResources>,
256 ) -> Result<Vec<(RouteConfig, Arc<CompiledGraph>)>, String> {
257 let gateway = resolve_plugin_configs(gateway)?;
262 let gateway = &gateway;
263
264 for warning in crate::config::collect_template_warnings(gateway) {
271 tracing::warn!("{warning}");
272 }
273
274 let mut seen = std::collections::HashSet::new();
277 for sn in &gateway.supernodes {
278 if !seen.insert(sn.name.as_str()) {
279 return Err(format!("Duplicate supernode name '{}'", sn.name));
280 }
281 if let Err(errors) = validate_supernode(sn) {
282 return Err(format!("Invalid supernode '{}': {:?}", sn.name, errors));
283 }
284 }
285
286 let mut policy_map = std::collections::HashMap::new();
287 for policy in &gateway.policies {
288 if let Err(errors) = validate_policy(policy) {
289 return Err(format!("Invalid policy '{}': {:?}", policy.name, errors));
290 }
291 let expanded = expand_policy(policy, &gateway.supernodes)?;
293 let compiled = compile_policy(&expanded, resources.clone())?;
294 policy_map.insert(policy.name.clone(), Arc::new(compiled));
295 }
296
297 let mut routes = Vec::new();
298 for route in &gateway.routes {
299 let graph = policy_map
300 .get(&route.policy)
301 .ok_or(format!(
302 "Route '{}' references unknown policy '{}'",
303 route.name, route.policy
304 ))?
305 .clone();
306 let mut route = route.clone();
309 route.match_rule.interpolate_env();
310 routes.push((route, graph));
311 }
312 Ok(routes)
313 }
314}
315
316pub fn validate_gateway_config(gw: &GatewayConfig) -> Result<(), String> {
324 crate::consumers::ConsumerStore::from_config(&gw.consumers)?;
325 SharedState::compile_routes(gw, &PluginResources::new(None))?;
326 Ok(())
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 fn state_from_yaml(gateway_yaml: &str) -> Result<(), String> {
334 let system: crate::config::SystemConfig = serde_yaml::from_str("{}").unwrap();
335 let gw: crate::config::GatewayConfig = serde_yaml::from_str(gateway_yaml).unwrap();
336 let state = SharedState::new(
337 system,
338 serde_yaml::from_str("{}").unwrap(),
339 None,
340 std::sync::Arc::new(crate::config_store::FileConfigStore::new(
341 std::path::PathBuf::from("gateway.yaml"),
342 )),
343 )
344 .unwrap();
345 state.validate_gateway(&gw)
346 }
347
348 const SUPERNODE_GATEWAY: &str = r#"
349supernodes:
350 - name: secured-call
351 nodes:
352 - { id: input, type: input }
353 - { id: output, type: output }
354 - { id: error, type: error }
355 - { id: up, type: upstream, config: { targets: [{ host: "127.0.0.1", port: 9 }] } }
356 edges:
357 - { from: input.out, to: up.in }
358 - { from: up.success, to: output.in }
359routes:
360 - name: r
361 match: { path: "/*" }
362 policy: p
363policies:
364 - name: p
365 nodes:
366 - { id: listener, type: listener }
367 - { id: sec, type: supernode, config: { name: secured-call } }
368 - { id: client, type: client }
369 edges:
370 - { from: listener.out, to: sec.in }
371 - { from: sec.success, to: client.in }
372"#;
373
374 #[tokio::test]
379 async fn test_rejected_apply_is_logged_at_warn() {
380 let system: crate::config::SystemConfig = serde_yaml::from_str("{}").unwrap();
381 let state = SharedState::new(
382 system,
383 serde_yaml::from_str("{}").unwrap(),
384 None,
385 std::sync::Arc::new(crate::config_store::FileConfigStore::new(
386 std::path::PathBuf::from("gateway.yaml"),
387 )),
388 )
389 .unwrap();
390 let candidate: crate::config::GatewayConfig = serde_yaml::from_str(
392 r#"
393routes:
394 - name: r
395 match: { path: "/*" }
396 policy: p
397policies:
398 - name: p
399 nodes:
400 - { id: listener, type: listener }
401 - { id: client, type: client }
402 edges: []
403"#,
404 )
405 .unwrap();
406
407 let (_guard, logs) = crate::test_log::capture_warnings();
408 let err = state.apply_gateway(candidate).await.unwrap_err();
409
410 let out = logs.contents();
411 assert!(out.contains("WARN"), "expected a WARN line, got: {out:?}");
412 assert!(
413 out.contains("Rejected config") && out.contains(&err),
414 "expected the rejection reason {err:?} in the log, got: {out:?}"
415 );
416 }
417
418 #[test]
419 fn test_policy_with_supernode_compiles() {
420 assert_eq!(state_from_yaml(SUPERNODE_GATEWAY), Ok(()));
421 }
422
423 #[test]
424 fn test_route_match_resolves_env_placeholders_in_route_table() {
425 std::env::set_var("TEST_ROUTE_PREFIX", "/env-api");
429 let gw: crate::config::GatewayConfig = serde_yaml::from_str(
430 r#"
431routes:
432 - name: r
433 match:
434 path: "${TEST_ROUTE_PREFIX}/*"
435 host: "${TEST_ROUTE_HOST:-api.example.com}"
436 headers: { x-tier: "${TEST_ROUTE_TIER:-gold}" }
437 policy: p
438policies:
439 - name: p
440 nodes:
441 - { id: listener, type: listener }
442 - { id: client, type: client }
443 edges:
444 - { from: listener.out, to: client.in }
445"#,
446 )
447 .unwrap();
448
449 let routes = SharedState::compile_routes(&gw, &PluginResources::new(None)).unwrap();
450 let rule = &routes[0].0.match_rule;
451 assert_eq!(rule.path.as_deref(), Some("/env-api/*"));
452 assert_eq!(rule.host.as_deref(), Some("api.example.com"));
453 assert_eq!(rule.headers["x-tier"], "gold");
454 assert_eq!(
456 gw.routes[0].match_rule.path.as_deref(),
457 Some("${TEST_ROUTE_PREFIX}/*")
458 );
459 std::env::remove_var("TEST_ROUTE_PREFIX");
460 }
461
462 #[test]
467 fn test_validate_gateway_config_is_a_standalone_dry_run() {
468 let good: crate::config::GatewayConfig = serde_yaml::from_str(SUPERNODE_GATEWAY).unwrap();
469 assert_eq!(validate_gateway_config(&good), Ok(()));
470
471 let broken: crate::config::GatewayConfig = serde_yaml::from_str(
472 r#"
473routes:
474 - name: r
475 match: { path: "/*" }
476 policy: p
477policies:
478 - name: p
479 nodes:
480 - { id: listener, type: listener }
481 - { id: auth, type: key-auth, config: { use_consumers: true } }
482 - { id: client, type: client }
483 edges:
484 - { from: listener.out, to: auth.in }
485 - { from: auth.success, to: client.in }
486"#,
487 )
488 .unwrap();
489 let err = validate_gateway_config(&broken).unwrap_err();
490 assert!(
491 err.contains("denied") && err.contains("must be wired"),
492 "{err}"
493 );
494 }
495
496 #[test]
497 fn test_unknown_supernode_reference_rejected() {
498 let yaml = SUPERNODE_GATEWAY.replace("name: secured-call } }", "name: nope } }");
499 let err = state_from_yaml(&yaml).unwrap_err();
500 assert!(err.contains("unknown supernode"), "{err}");
501 }
502
503 #[test]
504 fn test_invalid_supernode_definition_rejected() {
505 let yaml = r#"
507supernodes:
508 - name: secured-call
509 nodes:
510 - { id: output, type: output }
511 - { id: error, type: error }
512 - { id: up, type: upstream, config: { targets: [{ host: "127.0.0.1", port: 9 }] } }
513 edges:
514 - { from: up.success, to: output.in }
515routes:
516 - name: r
517 match: { path: "/*" }
518 policy: p
519policies:
520 - name: p
521 nodes:
522 - { id: listener, type: listener }
523 - { id: sec, type: supernode, config: { name: secured-call } }
524 - { id: client, type: client }
525 edges:
526 - { from: listener.out, to: sec.in }
527 - { from: sec.success, to: client.in }
528"#;
529 let err = state_from_yaml(yaml).unwrap_err();
530 assert!(err.contains("Invalid supernode"), "{err}");
531 }
532
533 #[test]
534 fn test_duplicate_supernode_names_rejected() {
535 let yaml = r#"
536supernodes:
537 - name: secured-call
538 nodes:
539 - { id: input, type: input }
540 - { id: output, type: output }
541 - { id: error, type: error }
542 - { id: up, type: upstream, config: { targets: [{ host: "127.0.0.1", port: 9 }] } }
543 edges:
544 - { from: input.out, to: up.in }
545 - { from: up.success, to: output.in }
546 - name: secured-call
547 nodes: []
548 edges: []
549routes:
550 - name: r
551 match: { path: "/*" }
552 policy: p
553policies:
554 - name: p
555 nodes:
556 - { id: listener, type: listener }
557 - { id: sec, type: supernode, config: { name: secured-call } }
558 - { id: client, type: client }
559 edges:
560 - { from: listener.out, to: sec.in }
561 - { from: sec.success, to: client.in }
562"#;
563 let err = state_from_yaml(yaml).unwrap_err();
564 assert!(err.contains("Duplicate supernode"), "{err}");
565 }
566
567 const PLUGIN_CONFIG_GATEWAY: &str = r#"
572plugin_configs:
573 - name: shared-up
574 type: upstream
575 config: { targets: [ { host: "127.0.0.1", port: 9 } ] }
576supernodes:
577 - name: wrapped
578 nodes:
579 - { id: input, type: input }
580 - { id: output, type: output }
581 - { id: error, type: error }
582 - { id: up, type: upstream, config_ref: shared-up }
583 edges:
584 - { from: input.out, to: up.in }
585 - { from: up.success, to: output.in }
586routes:
587 - name: r
588 match: { path: "/*" }
589 policy: p
590policies:
591 - name: p
592 nodes:
593 - { id: listener, type: listener }
594 - { id: direct, type: upstream, config_ref: shared-up, config: { strategy: "round_robin" } }
595 - { id: sn, type: supernode, config: { name: wrapped } }
596 - { id: client, type: client }
597 edges:
598 - { from: listener.out, to: direct.in }
599 - { from: direct.success, to: sn.in }
600 - { from: sn.success, to: client.in }
601"#;
602
603 #[test]
606 fn test_plugin_config_refs_compile() {
607 assert_eq!(state_from_yaml(PLUGIN_CONFIG_GATEWAY), Ok(()));
608 }
609
610 #[test]
611 fn test_unknown_plugin_config_ref_rejected() {
612 let yaml = PLUGIN_CONFIG_GATEWAY.replace(
613 "config_ref: shared-up, config:",
614 "config_ref: nope, config:",
615 );
616 let err = state_from_yaml(&yaml).unwrap_err();
617 assert!(err.contains("unknown plugin config 'nope'"), "{err}");
618 }
619
620 #[test]
623 fn test_delete_referenced_plugin_config_rejected() {
624 let yaml = PLUGIN_CONFIG_GATEWAY.replace(
625 " - name: shared-up\n type: upstream\n config: { targets: [ { host: \"127.0.0.1\", port: 9 } ] }\n",
626 "",
627 );
628 let err = state_from_yaml(&yaml).unwrap_err();
629 assert!(err.contains("unknown plugin config 'shared-up'"), "{err}");
630 }
631
632 #[tokio::test]
636 async fn test_stores_validated_at_compile_and_kept_raw() {
637 let system: crate::config::SystemConfig = serde_yaml::from_str("{}").unwrap();
638 let gw: crate::config::GatewayConfig = serde_yaml::from_str(
639 "stores:\n - name: s1\n type: redis\n url: ${STORE_TEST_URL:-redis://127.0.0.1:6379}\n",
640 )
641 .unwrap();
642 let result = SharedState::new(
643 system,
644 gw,
645 None,
646 std::sync::Arc::new(crate::config_store::FileConfigStore::new(
647 std::path::PathBuf::from("gateway.yaml"),
648 )),
649 );
650 let state = result.unwrap();
651
652 let gw = state.gateway.read().await;
654 assert_eq!(
655 gw.stores[0].url,
656 "${STORE_TEST_URL:-redis://127.0.0.1:6379}"
657 );
658 drop(gw);
659
660 let bad: crate::config::GatewayConfig = serde_yaml::from_str(
662 "stores:\n - name: d\n type: redis\n url: redis://a\n - name: d\n type: redis\n url: redis://b\n",
663 )
664 .unwrap();
665 let err = state.apply_gateway(bad).await.unwrap_err();
666 assert!(err.contains("Duplicate store name 'd'"), "{err}");
667
668 assert_eq!(state.gateway.read().await.stores.len(), 1);
670 }
671
672 #[cfg(feature = "redis-store")]
675 #[test]
676 fn test_limit_count_redis_store_resolved_at_compile() {
677 let policy_yaml = |store_line: &str| {
678 format!(
679 r#"
680{store_line}
681policies:
682 - name: p
683 nodes:
684 - {{ id: l, type: listener }}
685 - {{ id: lc, type: limit-count, config: {{ count: 1, time_window: 60, policy: redis, store: s1 }} }}
686 - {{ id: c, type: client }}
687 edges:
688 - {{ from: l.out, to: lc.in }}
689 - {{ from: lc.success, to: c.in }}
690 - {{ from: lc.limited, to: c.in }}
691routes:
692 - name: r
693 match: {{ path: "/x" }}
694 policy: p
695"#
696 )
697 };
698
699 let with_store: crate::config::GatewayConfig = serde_yaml::from_str(&policy_yaml(
700 "stores:\n - name: s1\n type: redis\n url: redis://127.0.0.1:6379",
701 ))
702 .unwrap();
703 validate_gateway_config(&with_store).expect("declared store must compile");
704
705 let without_store: crate::config::GatewayConfig =
706 serde_yaml::from_str(&policy_yaml("")).unwrap();
707 let err = validate_gateway_config(&without_store).unwrap_err();
708 assert!(err.contains("unknown store 's1'"), "{err}");
709 }
710}