Skip to main content

featherbit/
state.rs

1//! Shared, lock-protected gateway state used by the data plane, the Admin
2//! API, and the hot-reload watcher. Owns the current gateway config and the
3//! routes compiled from it, and provides the recompile/swap operations.
4
5use 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
17/// The compiled route table: each route paired with its compiled policy graph.
18type CompiledRoutes = Vec<(RouteConfig, Arc<CompiledGraph>)>;
19
20/// Shared gateway state, accessible from both the data-plane server and the Admin API.
21///
22/// Wrapped in an [`Arc`] and cloned into every server task. The data plane
23/// only ever takes short **read** locks on `routes` while matching a request;
24/// **write** locks are taken by the Admin API and hot-reload paths when
25/// swapping in a freshly compiled route table. Route recompilation happens on
26/// [`SharedState::reload`] / [`SharedState::reload_from_disk`] — never on the
27/// request path.
28pub struct SharedState {
29    /// Immutable system-level configuration (`system.yaml`); fixed for the process lifetime.
30    #[allow(dead_code)] // callers take `&SystemConfig` directly; kept on state for reference
31    pub system: SystemConfig,
32    /// Current gateway configuration (`gateway.yaml`), mutated by the Admin API CRUD endpoints.
33    pub gateway: RwLock<GatewayConfig>,
34    /// Route table: each route paired with the compiled graph of the policy it references.
35    /// Kept in declaration order; the first matching route wins.
36    pub routes: RwLock<Vec<(RouteConfig, Arc<CompiledGraph>)>>,
37    /// Path to `gateway.yaml`, if known; required for [`SharedState::reload_from_disk`].
38    pub config_path: Option<std::path::PathBuf>,
39    /// Process-wide Prometheus registry. Compiled graphs record per-node
40    /// metrics into it, the data plane records per-request metrics, and the
41    /// Admin API's `/metrics` endpoint renders it.
42    pub metrics: Arc<GatewayMetrics>,
43    /// Shared plugin services (metrics handle, shared clients), threaded into
44    /// every plugin at policy compile time.
45    pub resources: Arc<PluginResources>,
46    /// Backend the config is loaded from and Admin API mutations are persisted
47    /// to (file by default; etcd for HA clusters).
48    pub config_store: Arc<dyn ConfigStore>,
49    /// Debug-mode settings and the bounded trace buffer. Written by the data
50    /// plane when a request opts into tracing, read by the Admin API. Fixed at
51    /// startup — `system.yaml` is not hot-reloaded.
52    pub debug: Arc<DebugState>,
53    /// The running ACME runtime (managed certs, solver, renewal manager), set
54    /// once at startup when `system.tls.acme`/`sni_certs[].acme` is configured.
55    /// `None` when ACME is not in use. Drives `/readyz`'s placeholder gate.
56    pub acme: arc_swap::ArcSwapOption<crate::acme::AcmeRuntime>,
57    /// Whether `system.yaml` asks for ACME-managed certificates at all
58    /// (`acme:` present **and** at least one managed TLS slot). The runtime in
59    /// `acme` is only populated once `server::start_server` has seeded it,
60    /// which happens after the admin listener is already serving — so
61    /// `/readyz` uses this to answer "not ready" instead of "ready" during
62    /// that window.
63    pub acme_expected: bool,
64}
65
66impl SharedState {
67    /// Creates the shared state, validating and compiling every policy up front.
68    ///
69    /// Fails if any policy is invalid or a route references an unknown policy,
70    /// so a successfully constructed `SharedState` always has a usable route table.
71    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    /// Validates and compiles `new_gw`, then atomically swaps the consumer
126    /// store, route table, and in-memory gateway config.
127    ///
128    /// This is the single swap path used by every config driver (file watcher,
129    /// etcd watch, Admin API commits). All fallible work — consumer-store build
130    /// and policy compilation — happens **before** any swap, so a failure
131    /// leaves the running config untouched (the last-good guarantee).
132    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                // A rejected candidate leaves the running config untouched, so
137                // this line is the only server-side trace that a save (from
138                // the UI, the Admin API, the file watcher or etcd) was refused.
139                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    /// Builds everything a swap needs — consumer store and compiled route
157    /// table — failing before anything is touched.
158    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    /// Validates and compiles `gw` **without** swapping anything.
168    ///
169    /// Config stores call this to reject a candidate config before persisting
170    /// it, so the Admin API can return an error synchronously even when the
171    /// change will be applied asynchronously by a watch.
172    ///
173    /// Note: on success the candidate store registry remains loaded in
174    /// resources.stores; it is only ever read during a compile and is
175    /// replaced by the next one, so this is not applied config.
176    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    /// `validate_gateway`, but guaranteed to leave `resources.stores` exactly
183    /// as it found it, on both success and failure.
184    ///
185    /// `compile_routes` only restores the pre-compile store registry when it
186    /// *fails* — on success the candidate registry is left live, because
187    /// every other caller (`apply_gateway`, config-store commits) follows a
188    /// successful validate with an apply that installs that same candidate
189    /// for real. A true dry-run has no such follow-up: without this, a
190    /// `dry_run: true` MCP write (e.g. `delete_store`) would durably swap in
191    /// the candidate registry — tearing down a live store's client (breaking
192    /// `/api/sessions`/ACME redis storage until the next apply) or standing
193    /// up a client for a store that was never committed — even though
194    /// nothing was meant to change. Use this wherever validation must not
195    /// have that side effect.
196    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    /// Reloads from disk (re-reads `gateway.yaml` raw, keeping `${VAR}`
204    /// placeholders — resolution happens at compile/build time), recompiles,
205    /// and swaps in the new config.
206    ///
207    /// Invoked by the hot-reload file watcher. Fails without side effects if
208    /// `config_path` is unset, the file cannot be parsed, or compilation fails.
209    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    /// Parses `gateway.yaml` from `config_path` without applying it (raw,
215    /// `${VAR}` placeholders kept). Lets callers compare the file against the
216    /// live config before a reload discards in-memory edits.
217    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    /// Validates and compiles every policy, then binds each route to its
226    /// compiled graph. Policies shared by multiple routes are compiled once
227    /// and shared via `Arc`.
228    fn compile_routes(
229        gateway: &GatewayConfig,
230        resources: &Arc<PluginResources>,
231    ) -> Result<CompiledRoutes, String> {
232        crate::stores::validate_stores(&gateway.stores)?;
233        // Swap the candidate store registry in for the duration of the
234        // compile (plugins resolve `store:` names at construction). The
235        // registry is never read on the request path, so this transient swap
236        // cannot affect in-flight traffic; on failure the previous registry
237        // is restored, preserving the last-good invariant.
238        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    /// The pre-stores compile body; called with the candidate registry already swapped in.
253    fn compile_routes_inner(
254        gateway: &GatewayConfig,
255        resources: &Arc<PluginResources>,
256    ) -> Result<Vec<(RouteConfig, Arc<CompiledGraph>)>, String> {
257        // Materialize shared plugin configs first: supernode definitions and
258        // policies both resolve against them, and expansion below copies the
259        // resolved inner configs into instances. In-memory only — the stored
260        // gateway config keeps the `config_ref` form.
261        let gateway = resolve_plugin_configs(gateway)?;
262        let gateway = &gateway;
263
264        // Surface likely template typos (e.g. `{{request.headres.x}}`) at
265        // load time rather than letting them render as silent literals on
266        // every request. Runs on the *resolved* copy so a config_ref'd node's
267        // merged-in shared config is covered via the policy/supernode walk;
268        // plugin_configs are walked directly too, so an unreferenced shared
269        // config is still flagged. Advisory only — never fails compilation.
270        for warning in crate::config::collect_template_warnings(gateway) {
271            tracing::warn!("{warning}");
272        }
273
274        // Supernode definitions are validated first: policies expand against
275        // them, so a broken definition must fail before any policy does.
276        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            // Inline supernode instances; the engine never sees them.
292            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            // Resolve `${VAR}` in the route-table copy only; the stored
307            // config keeps the placeholder form (the Admin API serves it).
308            let mut route = route.clone();
309            route.match_rule.interpolate_env();
310            routes.push((route, graph));
311        }
312        Ok(routes)
313    }
314}
315
316/// Dry-run validation of a gateway config, without a live [`SharedState`].
317///
318/// Runs exactly the fallible work [`SharedState::validate_gateway`] does —
319/// consumer-store construction, supernode/policy validation, expansion, and
320/// policy compilation — against a throwaway resource set, so it can be called
321/// before any state exists. Used by the etcd seeder to reject a broken local
322/// `gateway.yaml` *before* writing it into an empty cluster prefix.
323pub 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    /// A candidate the compiler rejects must leave an operational trace: a
375    /// WARN line carrying the reason, whichever driver (file watcher, etcd,
376    /// Admin API) submitted it. Without it a rejected UI save is invisible
377    /// server-side.
378    #[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        // `client` is never reached: listener.out is unwired.
391        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        // gateway.yaml is loaded raw (placeholders intact, so the Admin API
426        // never serves resolved values); the compiled route table is where
427        // `${VAR}` in match rules must resolve.
428        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        // The stored config keeps the placeholder form.
455        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    /// I5: the etcd seeder's pre-write gate. It must reach the same verdict as
463    /// `validate_gateway` without needing a live `SharedState` — accepting a
464    /// good config and rejecting one that cannot compile (here: `key-auth`'s
465    /// mandatory `denied` port left unwired).
466    #[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        // Missing the input boundary node -> validate_supernode must fail.
506        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    // `upstream` is used deliberately: its `targets` key is REQUIRED, so an
568    // UNRESOLVED ref leaves the node without targets and create_plugin fails —
569    // giving this test a genuine red state before resolution was wired in.
570    // (A permissive plugin like `mocking` would compile even unresolved.)
571    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    /// Refs resolve for a direct policy node AND for a node inside a
604    /// supernode definition, and the whole thing compiles.
605    #[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    /// Removing the shared config while a supernode inner node still
621    /// references it is rejected — this is the delete-protection mechanism.
622    #[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    /// `stores:` validation runs on every compile: duplicates are rejected
633    /// with the running config left intact, and the stored config keeps raw
634    /// `${...}` placeholders (the security invariant shared with routes).
635    #[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        // Stored config still holds the placeholder.
653        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        // A candidate with a duplicate store name is rejected...
661        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        // ...and the last-good config keeps serving.
669        assert_eq!(state.gateway.read().await.stores.len(), 1);
670    }
671
672    /// A limit-count node with `policy: redis` resolves its named store at
673    /// compile time: declared store compiles, missing store fails compile.
674    #[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}