1use 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
15fn 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 #[serde(default)]
48 pub name: Option<String>,
49 #[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 #[serde(default)]
63 pub dry_run: bool,
64}
65
66impl PutArgs {
67 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 pub name: String,
101 #[serde(default)]
103 pub dry_run: bool,
104}
105
106pub 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 state
120 .validate_gateway_dry(&candidate)
121 .map_err(|e| ToolError::invalid_config(vec![e]))?;
122 } else {
123 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 #[serde(default)]
280 pub discard_unsaved: bool,
281}
282
283fn 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
296pub 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
332pub 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 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 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 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 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 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 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 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 #[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 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 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 assert!(s.resources.stores.load().contains("st"));
721 }
722}