Skip to main content

featherbit/mcp/tools/
writes.rs

1//! Write tools. Every mutation builds a full candidate `GatewayConfig`, then
2//! either validates it (`dry_run`) or commits it through the configured
3//! `ConfigStore` — exactly the path the Admin API takes.
4
5use schemars::JsonSchema;
6use serde::Deserialize;
7use serde_json::Value;
8
9use super::ToolError;
10use crate::config::{
11    GatewayConfig, PluginConfigDef, PolicyConfig, RouteConfig, StoreConfig, SupernodeConfig,
12};
13use crate::state::SharedState;
14
15/// Like [`super::parse_payload`], but the `name` argument wins over (or fills
16/// in) any `name` inside the payload before deserializing — payloads
17/// commonly omit `name` and rely on the tool argument, exactly as the REST
18/// `PUT /api/...{name}` handlers let the path segment override the body.
19fn parse_named<T: serde::de::DeserializeOwned>(
20    definition: Value,
21    name: &str,
22    what: &str,
23) -> Result<T, ToolError> {
24    let mut value: Value = match definition {
25        Value::String(yaml) => serde_yaml::from_str(&yaml)
26            .map_err(|e| ToolError::invalid_payload(format!("{what}: YAML did not parse: {e}")))?,
27        other => other,
28    };
29    match value.as_object_mut() {
30        Some(obj) => {
31            obj.insert("name".to_string(), Value::String(name.to_string()));
32        }
33        None => {
34            return Err(ToolError::invalid_payload(format!(
35                "{what}: definition must be a JSON/YAML object"
36            )))
37        }
38    }
39    serde_json::from_value(value)
40        .map_err(|e| ToolError::invalid_payload(format!("{what}: JSON did not deserialize: {e}")))
41}
42
43#[derive(Debug, Deserialize, JsonSchema)]
44pub struct PutArgs {
45    /// Resource name. Optional when the definition itself carries `name`;
46    /// when both are given this one wins.
47    #[serde(default)]
48    pub name: Option<String>,
49    /// The definition, as a JSON object or a YAML document string. Also
50    /// accepted under the resource's own key (`policy`, `route`, `supernode`,
51    /// `plugin_config`, `store`) or `body`.
52    #[serde(
53        alias = "policy",
54        alias = "route",
55        alias = "supernode",
56        alias = "plugin_config",
57        alias = "store",
58        alias = "body"
59    )]
60    pub definition: Value,
61    /// Validate the whole resulting config without applying it. Default false.
62    #[serde(default)]
63    pub dry_run: bool,
64}
65
66impl PutArgs {
67    /// The resource name: the `name` argument, else `name` inside the
68    /// definition (object or YAML string).
69    fn resolve_name(&self, what: &str) -> Result<String, ToolError> {
70        if let Some(n) = self
71            .name
72            .as_deref()
73            .map(str::trim)
74            .filter(|n| !n.is_empty())
75        {
76            return Ok(n.to_string());
77        }
78        let inner = match &self.definition {
79            Value::String(yaml) => serde_yaml::from_str::<Value>(yaml).ok(),
80            other => Some(other.clone()),
81        };
82        inner
83            .as_ref()
84            .and_then(|v| v.get("name"))
85            .and_then(Value::as_str)
86            .map(str::trim)
87            .filter(|n| !n.is_empty())
88            .map(str::to_string)
89            .ok_or_else(|| {
90                ToolError::invalid_payload(format!(
91                    "{what}: give `name` as an argument or inside the definition"
92                ))
93            })
94    }
95}
96
97#[derive(Debug, Deserialize, JsonSchema)]
98pub struct DeleteArgs {
99    /// Resource name.
100    pub name: String,
101    /// Validate the whole resulting config without applying it. Default false.
102    #[serde(default)]
103    pub dry_run: bool,
104}
105
106/// Applies `mutate` to a clone of the live config, then validates or commits.
107pub async fn commit_candidate(
108    state: &SharedState,
109    dry_run: bool,
110    changed: Vec<String>,
111    mutate: impl FnOnce(&mut GatewayConfig) -> Result<(), ToolError>,
112) -> Result<Value, ToolError> {
113    let mut candidate = state.gateway.read().await.clone();
114    mutate(&mut candidate)?;
115    if dry_run {
116        // `validate_gateway_dry` restores `resources.stores` on both success
117        // and failure, so a dry run never durably swaps in the candidate
118        // store registry (see its doc comment on `SharedState`).
119        state
120            .validate_gateway_dry(&candidate)
121            .map_err(|e| ToolError::invalid_config(vec![e]))?;
122    } else {
123        // Validate first so a store failure after a clean validation is
124        // reported as store_error, not invalid_config; `commit` re-validates
125        // and applies, so the candidate registry it leaves live is the one
126        // actually being committed.
127        state
128            .validate_gateway(&candidate)
129            .map_err(|e| ToolError::invalid_config(vec![e]))?;
130        state
131            .config_store
132            .clone()
133            .commit(state, candidate)
134            .await
135            .map_err(ToolError::store_error)?;
136    }
137    Ok(serde_json::json!({ "applied": !dry_run, "dry_run": dry_run, "changed": changed }))
138}
139
140fn upsert<T>(items: &mut Vec<T>, item: T, same: impl Fn(&T) -> bool) {
141    if let Some(existing) = items.iter_mut().find(|i| same(i)) {
142        *existing = item;
143    } else {
144        items.push(item);
145    }
146}
147
148fn remove<T>(
149    items: &mut Vec<T>,
150    what: &str,
151    name: &str,
152    same: impl Fn(&T) -> bool,
153) -> Result<(), ToolError> {
154    let before = items.len();
155    items.retain(|i| !same(i));
156    if items.len() == before {
157        Err(ToolError::not_found(what, name))
158    } else {
159        Ok(())
160    }
161}
162
163pub async fn put_route(state: &SharedState, a: PutArgs) -> Result<Value, ToolError> {
164    let name = a.resolve_name("route")?;
165    let route: RouteConfig = parse_named(a.definition, &name, "route")?;
166    commit_candidate(state, a.dry_run, vec![format!("route:{name}")], |gw| {
167        upsert(&mut gw.routes, route, |r| r.name == name);
168        Ok(())
169    })
170    .await
171}
172
173pub async fn delete_route(state: &SharedState, a: DeleteArgs) -> Result<Value, ToolError> {
174    commit_candidate(state, a.dry_run, vec![format!("route:{}", a.name)], |gw| {
175        remove(&mut gw.routes, "route", &a.name, |r| r.name == a.name)
176    })
177    .await
178}
179
180pub async fn put_policy(state: &SharedState, a: PutArgs) -> Result<Value, ToolError> {
181    let name = a.resolve_name("policy")?;
182    let policy: PolicyConfig = parse_named(a.definition, &name, "policy")?;
183    commit_candidate(state, a.dry_run, vec![format!("policy:{name}")], |gw| {
184        upsert(&mut gw.policies, policy, |p| p.name == name);
185        Ok(())
186    })
187    .await
188}
189
190pub async fn delete_policy(state: &SharedState, a: DeleteArgs) -> Result<Value, ToolError> {
191    commit_candidate(state, a.dry_run, vec![format!("policy:{}", a.name)], |gw| {
192        remove(&mut gw.policies, "policy", &a.name, |p| p.name == a.name)
193    })
194    .await
195}
196
197pub async fn put_supernode(state: &SharedState, a: PutArgs) -> Result<Value, ToolError> {
198    let name = a.resolve_name("supernode")?;
199    let sn: SupernodeConfig = parse_named(a.definition, &name, "supernode")?;
200    commit_candidate(state, a.dry_run, vec![format!("supernode:{name}")], |gw| {
201        upsert(&mut gw.supernodes, sn, |s| s.name == name);
202        Ok(())
203    })
204    .await
205}
206
207pub async fn delete_supernode(state: &SharedState, a: DeleteArgs) -> Result<Value, ToolError> {
208    commit_candidate(
209        state,
210        a.dry_run,
211        vec![format!("supernode:{}", a.name)],
212        |gw| {
213            remove(&mut gw.supernodes, "supernode", &a.name, |s| {
214                s.name == a.name
215            })
216        },
217    )
218    .await
219}
220
221pub async fn put_plugin_config(state: &SharedState, a: PutArgs) -> Result<Value, ToolError> {
222    let name = a.resolve_name("plugin config")?;
223    let pc: PluginConfigDef = parse_named(a.definition, &name, "plugin config")?;
224    commit_candidate(
225        state,
226        a.dry_run,
227        vec![format!("plugin_config:{name}")],
228        |gw| {
229            upsert(&mut gw.plugin_configs, pc, |p| p.name == name);
230            Ok(())
231        },
232    )
233    .await
234}
235
236pub async fn delete_plugin_config(state: &SharedState, a: DeleteArgs) -> Result<Value, ToolError> {
237    commit_candidate(
238        state,
239        a.dry_run,
240        vec![format!("plugin_config:{}", a.name)],
241        |gw| {
242            remove(&mut gw.plugin_configs, "plugin config", &a.name, |p| {
243                p.name == a.name
244            })
245        },
246    )
247    .await
248}
249
250pub async fn put_store(state: &SharedState, a: PutArgs) -> Result<Value, ToolError> {
251    let name = a.resolve_name("store")?;
252    let store: StoreConfig = parse_named(a.definition, &name, "store")?;
253    commit_candidate(state, a.dry_run, vec![format!("store:{name}")], |gw| {
254        upsert(&mut gw.stores, store, |s| s.name == name);
255        Ok(())
256    })
257    .await
258}
259
260pub async fn delete_store(state: &SharedState, a: DeleteArgs) -> Result<Value, ToolError> {
261    commit_candidate(state, a.dry_run, vec![format!("store:{}", a.name)], |gw| {
262        let referrers = crate::admin::stores::store_referrers(gw, &a.name);
263        if !referrers.is_empty() {
264            let mut e = ToolError::invalid_config(referrers);
265            e.message = format!("store '{}' is still referenced", a.name);
266            e.hint = Some("remove or repoint the referrers first".into());
267            return Err(e);
268        }
269        remove(&mut gw.stores, "store", &a.name, |s| s.name == a.name)
270    })
271    .await
272}
273
274#[derive(Debug, Default, Deserialize, JsonSchema)]
275pub struct ReloadArgs {
276    /// Re-read the file even though it would discard put_*/delete_* edits
277    /// that were never written to gateway.yaml. Default false: the tool then
278    /// refuses with `unsaved_changes` and lists what would be lost.
279    #[serde(default)]
280    pub discard_unsaved: bool,
281}
282
283/// Names of the entries in a named-resource section, keyed for comparison.
284fn named_json(section: &Value) -> Vec<(String, Value)> {
285    section
286        .as_array()
287        .map(|items| {
288            items
289                .iter()
290                .map(|v| (v["name"].as_str().unwrap_or("?").to_string(), v.clone()))
291                .collect()
292        })
293        .unwrap_or_default()
294}
295
296/// One line per resource that differs between the live config and the file:
297/// `policy 'p' only in memory`, `route 'r' only on disk`, `store 's' differs`.
298pub fn unsaved_changes(live: &GatewayConfig, disk: &GatewayConfig) -> Vec<String> {
299    let (live, disk) = match (serde_json::to_value(live), serde_json::to_value(disk)) {
300        (Ok(l), Ok(d)) => (l, d),
301        _ => return vec!["config could not be serialized for comparison".to_string()],
302    };
303    let mut out = Vec::new();
304    for (section, label) in [
305        ("routes", "route"),
306        ("policies", "policy"),
307        ("supernodes", "supernode"),
308        ("plugin_configs", "plugin config"),
309        ("stores", "store"),
310        ("consumers", "consumer"),
311    ] {
312        let mem = named_json(&live[section]);
313        let file = named_json(&disk[section]);
314        for (name, v) in &mem {
315            match file.iter().find(|(n, _)| n == name) {
316                None => out.push(format!("{label} '{name}' exists only in memory")),
317                Some((_, fv)) if fv != v => {
318                    out.push(format!("{label} '{name}' differs from the file"))
319                }
320                Some(_) => {}
321            }
322        }
323        for (name, _) in &file {
324            if !mem.iter().any(|(n, _)| n == name) {
325                out.push(format!("{label} '{name}' exists only on disk"));
326            }
327        }
328    }
329    out
330}
331
332/// Re-reads `gateway.yaml`. With the file config source, `put_*`/`delete_*`
333/// edits are live but never written back, so a reload silently reverts them —
334/// hence the guard: unless `discard_unsaved` is set, any difference between
335/// memory and disk is reported and nothing is reloaded.
336pub async fn reload_config(state: &SharedState, args: ReloadArgs) -> Result<Value, ToolError> {
337    let disk = state
338        .load_gateway_from_disk()
339        .map_err(ToolError::store_error)?;
340    let pending = {
341        let live = state.gateway.read().await;
342        unsaved_changes(&live, &disk)
343    };
344    if !pending.is_empty() && !args.discard_unsaved {
345        return Err(ToolError::unsaved_changes(pending));
346    }
347    state
348        .apply_gateway(disk)
349        .await
350        .map_err(ToolError::store_error)?;
351    Ok(serde_json::json!({ "status": "reloaded", "discarded": pending }))
352}
353
354#[cfg(test)]
355mod tests {
356    use crate::mcp::tools::call;
357    use crate::mcp::tools::test_support::{obj, state, ECHO_GATEWAY};
358
359    const NEW_POLICY: &str = "nodes:\n  - {id: l, type: listener}\n  - {id: e, type: echo, config: {body: hi}}\n  - {id: c, type: client}\nedges:\n  - {from: l.out, to: e.in}\n  - {from: e.out, to: c.in}\n";
360
361    /// A file-backed state whose `config_path` points at a temp copy of
362    /// `ECHO_GATEWAY`, so reload_config has a real file to compare against.
363    fn file_backed_state(
364        tag: &str,
365    ) -> (
366        std::sync::Arc<crate::state::SharedState>,
367        std::path::PathBuf,
368    ) {
369        use crate::config::{GatewayConfig, SystemConfig};
370        use crate::config_store::FileConfigStore;
371        let path =
372            std::env::temp_dir().join(format!("fb_reload_{tag}_{}.yaml", std::process::id()));
373        std::fs::write(&path, ECHO_GATEWAY).unwrap();
374        let system: SystemConfig = serde_yaml::from_str("{}").unwrap();
375        let gateway: GatewayConfig = serde_yaml::from_str(ECHO_GATEWAY).unwrap();
376        let s = crate::state::SharedState::new(
377            system,
378            gateway,
379            Some(path.clone()),
380            std::sync::Arc::new(FileConfigStore::new(path.clone())),
381        )
382        .unwrap();
383        (std::sync::Arc::new(s), path)
384    }
385
386    #[test]
387    fn unsaved_changes_lists_per_resource_differences() {
388        use crate::config::GatewayConfig;
389        let disk: GatewayConfig = serde_yaml::from_str(ECHO_GATEWAY).unwrap();
390        let mut live = disk.clone();
391        let mut extra = live.policies[0].clone();
392        extra.name = "p2".to_string();
393        live.policies.push(extra);
394        live.routes[0].policy = "some-other-policy".to_string();
395        let diffs = super::unsaved_changes(&live, &disk);
396        assert!(
397            diffs
398                .iter()
399                .any(|d| d == "policy 'p2' exists only in memory"),
400            "{diffs:?}"
401        );
402        assert!(
403            diffs.iter().any(|d| d.contains("route 'hello' differs")),
404            "{diffs:?}"
405        );
406        assert!(super::unsaved_changes(&disk, &disk).is_empty());
407    }
408
409    #[tokio::test]
410    async fn reload_refuses_to_discard_live_edits_unless_told_to() {
411        let (s, path) = file_backed_state("guard");
412        // Live edit that is not in the file.
413        call(
414            &s,
415            "put_policy",
416            obj(serde_json::json!({"name": "p2", "definition": NEW_POLICY})),
417        )
418        .await
419        .unwrap();
420
421        let err = call(&s, "reload_config", obj(serde_json::json!({})))
422            .await
423            .unwrap_err();
424        assert_eq!(err.code, "unsaved_changes");
425        assert!(
426            err.errors.iter().any(|e| e.contains("policy 'p2'")),
427            "{err:?}"
428        );
429        assert!(
430            s.gateway
431                .read()
432                .await
433                .policies
434                .iter()
435                .any(|p| p.name == "p2"),
436            "nothing reloaded"
437        );
438
439        let v = call(
440            &s,
441            "reload_config",
442            obj(serde_json::json!({"discard_unsaved": true})),
443        )
444        .await
445        .unwrap();
446        assert_eq!(v["status"], "reloaded");
447        assert!(v["discarded"]
448            .as_array()
449            .unwrap()
450            .iter()
451            .any(|d| d.as_str().unwrap().contains("p2")));
452        assert!(!s
453            .gateway
454            .read()
455            .await
456            .policies
457            .iter()
458            .any(|p| p.name == "p2"));
459
460        // In sync again: a plain reload is fine.
461        let v = call(&s, "reload_config", obj(serde_json::json!({})))
462            .await
463            .unwrap();
464        assert_eq!(v["discarded"], serde_json::json!([]));
465        let _ = std::fs::remove_file(path);
466    }
467
468    #[tokio::test]
469    async fn put_accepts_resource_key_alias_and_name_inside_definition() {
470        let s = state("{}", ECHO_GATEWAY);
471        // `policy` instead of `definition`, and the name carried inside it.
472        let mut def: serde_json::Value = serde_yaml::from_str(NEW_POLICY).unwrap();
473        def["name"] = serde_json::json!("p-alias");
474        let v = call(&s, "put_policy", obj(serde_json::json!({"policy": def})))
475            .await
476            .unwrap();
477        assert_eq!(v["changed"][0], "policy:p-alias");
478        assert!(s
479            .gateway
480            .read()
481            .await
482            .policies
483            .iter()
484            .any(|p| p.name == "p-alias"));
485
486        // No name anywhere: invalid_input with the shape hint.
487        let err = call(
488            &s,
489            "put_policy",
490            obj(serde_json::json!({"definition": NEW_POLICY})),
491        )
492        .await
493        .unwrap_err();
494        assert_eq!(err.code, "invalid_input");
495        assert!(err.message.contains("name"), "{err:?}");
496        assert!(
497            err.hint.as_deref().unwrap_or("").contains("\"definition\""),
498            "{err:?}"
499        );
500    }
501
502    #[tokio::test]
503    async fn dry_run_validates_without_applying() {
504        let s = state("{}", ECHO_GATEWAY);
505        let v = call(
506            &s,
507            "put_policy",
508            obj(serde_json::json!({"name": "p2", "definition": NEW_POLICY, "dry_run": true})),
509        )
510        .await
511        .unwrap();
512        assert_eq!(v["applied"], false);
513        assert_eq!(v["dry_run"], true);
514        assert_eq!(v["changed"][0], "policy:p2");
515        assert!(s
516            .gateway
517            .read()
518            .await
519            .policies
520            .iter()
521            .all(|p| p.name != "p2"));
522    }
523
524    #[tokio::test]
525    async fn put_then_route_then_delete_are_applied_and_hot_compiled() {
526        let s = state("{}", ECHO_GATEWAY);
527        let v = call(
528            &s,
529            "put_policy",
530            obj(serde_json::json!({"name": "p2", "definition": NEW_POLICY})),
531        )
532        .await
533        .unwrap();
534        assert_eq!(v["applied"], true);
535        assert!(s
536            .gateway
537            .read()
538            .await
539            .policies
540            .iter()
541            .any(|p| p.name == "p2"));
542
543        call(
544            &s,
545            "put_route",
546            obj(serde_json::json!({"name": "r2", "definition": {"match": {"path": "/two"}, "policy": "p2"}})),
547        )
548        .await
549        .unwrap();
550        assert_eq!(s.routes.read().await.len(), 2);
551
552        // Deleting a policy still referenced by a route is rejected whole.
553        let err = call(&s, "delete_policy", obj(serde_json::json!({"name": "p2"})))
554            .await
555            .unwrap_err();
556        assert_eq!(err.code, "invalid_config");
557        assert!(err.errors[0].contains("p2"), "{:?}", err.errors);
558
559        call(&s, "delete_route", obj(serde_json::json!({"name": "r2"})))
560            .await
561            .unwrap();
562        call(&s, "delete_policy", obj(serde_json::json!({"name": "p2"})))
563            .await
564            .unwrap();
565        assert_eq!(s.routes.read().await.len(), 1);
566        let err = call(&s, "delete_policy", obj(serde_json::json!({"name": "p2"})))
567            .await
568            .unwrap_err();
569        assert_eq!(err.code, "not_found");
570    }
571
572    #[tokio::test]
573    async fn invalid_policy_is_rejected_with_engine_errors() {
574        let s = state("{}", ECHO_GATEWAY);
575        let bad = "nodes:\n  - {id: l, type: listener}\n  - {id: k, type: key-auth, config: {keys: [k1]}}\n  - {id: c, type: client}\nedges:\n  - {from: l.out, to: k.in}\n  - {from: k.out, to: c.in}\n";
576        let err = call(
577            &s,
578            "put_policy",
579            obj(serde_json::json!({"name": "bad", "definition": bad})),
580        )
581        .await
582        .unwrap_err();
583        assert_eq!(err.code, "invalid_config");
584        assert!(err.hint.as_deref().unwrap().contains("wired"));
585        assert!(s
586            .gateway
587            .read()
588            .await
589            .policies
590            .iter()
591            .all(|p| p.name != "bad"));
592        let err = call(
593            &s,
594            "put_policy",
595            obj(serde_json::json!({"name": "bad", "definition": "nodes: ["})),
596        )
597        .await
598        .unwrap_err();
599        assert_eq!(err.code, "invalid_input");
600    }
601
602    #[tokio::test]
603    async fn supernode_plugin_config_store_round_trip() {
604        let s = state("{}", ECHO_GATEWAY);
605        call(
606            &s,
607            "put_plugin_config",
608            obj(serde_json::json!({"name": "shared-echo", "definition": {"type": "echo", "config": {"body": "hi"}}})),
609        )
610        .await
611        .unwrap();
612        assert_eq!(s.gateway.read().await.plugin_configs.len(), 1);
613        call(
614            &s,
615            "delete_plugin_config",
616            obj(serde_json::json!({"name": "shared-echo"})),
617        )
618        .await
619        .unwrap();
620
621        // Stores: only exercised when the redis-store feature is compiled in
622        // (without it, declaring a store fails validation by design).
623        if cfg!(feature = "redis-store") {
624            call(
625                &s,
626                "put_store",
627                obj(serde_json::json!({"name": "st", "definition": {"type": "redis", "url": "redis://127.0.0.1:1"}})),
628            )
629            .await
630            .unwrap();
631            call(
632                &s,
633                "put_plugin_config",
634                obj(serde_json::json!({"name": "lc", "definition": {"type": "limit-count", "config": {"count": 1, "time_window": 1, "policy": "redis", "store": "st"}}})),
635            )
636            .await
637            .unwrap();
638            let err = call(&s, "delete_store", obj(serde_json::json!({"name": "st"})))
639                .await
640                .unwrap_err();
641            assert_eq!(err.code, "invalid_config");
642            assert!(
643                err.errors[0].contains("plugin_config 'lc'"),
644                "{:?}",
645                err.errors
646            );
647            call(
648                &s,
649                "delete_plugin_config",
650                obj(serde_json::json!({"name": "lc"})),
651            )
652            .await
653            .unwrap();
654            call(&s, "delete_store", obj(serde_json::json!({"name": "st"})))
655                .await
656                .unwrap();
657        }
658    }
659
660    /// `dry_run: true` must not leave a durable trace in the live store
661    /// registry: `compile_routes` (called by `validate_gateway`) installs the
662    /// candidate registry as a side effect of validating, and only restores
663    /// the previous one on *failure* — every other caller follows with an
664    /// apply that keeps that candidate for real. The MCP dry-run path has no
665    /// such follow-up, so without `validate_gateway_dry` a dry-run
666    /// `delete_store` would durably drop the store's live client and a
667    /// dry-run `put_store` would durably stand one up.
668    #[cfg(feature = "redis-store")]
669    #[tokio::test]
670    async fn store_dry_run_does_not_swap_the_live_registry() {
671        let gw = format!(
672            "{ECHO_GATEWAY}\nstores:\n  - name: st\n    type: redis\n    url: redis://127.0.0.1:1\n"
673        );
674        let s = state("{}", &gw);
675        assert!(s.resources.stores.load().contains("st"));
676
677        // dry_run delete_store: registry and gateway config both keep 'st'.
678        let v = call(
679            &s,
680            "delete_store",
681            obj(serde_json::json!({"name": "st", "dry_run": true})),
682        )
683        .await
684        .unwrap();
685        assert_eq!(v["applied"], false);
686        assert!(
687            s.resources.stores.load().contains("st"),
688            "dry-run delete_store must not durably drop the live client"
689        );
690        assert!(s
691            .gateway
692            .read()
693            .await
694            .stores
695            .iter()
696            .any(|store| store.name == "st"));
697
698        // dry_run put_store for a brand-new name: registry never gets it.
699        let v = call(
700            &s,
701            "put_store",
702            obj(serde_json::json!({"name": "new-st", "dry_run": true, "definition": {"type": "redis", "url": "redis://127.0.0.1:1"}})),
703        )
704        .await
705        .unwrap();
706        assert_eq!(v["applied"], false);
707        assert!(
708            !s.resources.stores.load().contains("new-st"),
709            "dry-run put_store must not durably install an uncommitted client"
710        );
711        assert!(s
712            .gateway
713            .read()
714            .await
715            .stores
716            .iter()
717            .all(|store| store.name != "new-st"));
718
719        // Sanity: the original store is still functionally there afterwards.
720        assert!(s.resources.stores.load().contains("st"));
721    }
722}