1use std::sync::Arc;
14
15use axum::extract::{Path, Query, State};
16use axum::http::StatusCode;
17use axum::response::IntoResponse;
18use axum::routing::{get, post};
19use axum::{Json, Router};
20
21use crate::debug::render::{apply_filter, render_trace, TraceFilter};
22use crate::debug::sandbox::SandboxRequest;
23use crate::state::SharedState;
24
25pub fn router() -> Router<Arc<SharedState>> {
27 Router::new()
28 .route("/api/debug/config", get(get_config))
29 .route("/api/debug/traces", get(list_traces).delete(clear_traces))
30 .route("/api/debug/traces/{id}", get(get_trace))
31 .route("/api/debug/sandbox", post(run_sandbox))
32}
33
34fn disabled(what: &str) -> axum::response::Response {
37 tracing::warn!(
38 "{} was requested but debug mode is off; set `debug.enabled: true` in \
39 system.yaml (or FEATHERBIT_DEBUG=true) and restart",
40 what
41 );
42 (
43 StatusCode::NOT_FOUND,
44 Json(serde_json::json!({"error": "not_found"})),
45 )
46 .into_response()
47}
48
49async fn get_config(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
55 let d = &state.debug;
56 Json(serde_json::json!({
57 "enabled": d.enabled,
58 "sandbox": d.sandbox_enabled,
59 "trigger_header": d.trigger_header,
60 "trace_all": d.trace_all,
61 "capture_bodies": d.capture_bodies,
62 "max_traces": d.max_traces,
63 "max_steps": d.max_steps,
64 "max_body_bytes": d.max_body_bytes,
65 "traces_stored": d.len(),
66 }))
67}
68
69async fn list_traces(
75 State(state): State<Arc<SharedState>>,
76 Query(filter): Query<TraceFilter>,
77) -> impl IntoResponse {
78 if !state.debug.enabled {
79 return disabled("trace listing");
80 }
81 let traces = apply_filter(state.debug.list(), &filter);
82 Json(serde_json::json!({
86 "traces": traces,
87 "retention": state.debug.retention(),
88 }))
89 .into_response()
90}
91
92async fn get_trace(
96 State(state): State<Arc<SharedState>>,
97 Path(id): Path<String>,
98) -> impl IntoResponse {
99 if !state.debug.enabled {
100 return disabled("trace retrieval");
101 }
102 match state.debug.get(&id) {
103 Some(trace) => Json(render_trace(&trace)).into_response(),
104 None => (
105 StatusCode::NOT_FOUND,
106 Json(serde_json::json!({"error": "not_found"})),
107 )
108 .into_response(),
109 }
110}
111
112async fn clear_traces(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
114 if !state.debug.enabled {
115 return disabled("trace clearing");
116 }
117 let removed = state.debug.clear();
118 Json(serde_json::json!({ "status": "cleared", "removed": removed })).into_response()
119}
120
121fn bad_request(msg: impl Into<String>) -> axum::response::Response {
122 (
123 StatusCode::BAD_REQUEST,
124 Json(serde_json::json!({"error": msg.into()})),
125 )
126 .into_response()
127}
128
129async fn run_sandbox(
142 State(state): State<Arc<SharedState>>,
143 Json(req): Json<SandboxRequest>,
144) -> impl IntoResponse {
145 use crate::debug::sandbox::{run_sandbox as run, SandboxError};
146 match run(&state, req).await {
147 Ok(r) => Json(serde_json::json!({
148 "mode": r.mode,
149 "policy": r.policy,
150 "warning": "plugins executed for real: outbound calls were made and shared \
151 rate-limit/breaker state was mutated",
152 "stored_trace_id": r.stored_trace_id,
153 "trace": r.trace,
154 }))
155 .into_response(),
156 Err(SandboxError::Disabled) => disabled("sandbox"),
157 Err(SandboxError::SandboxDisabled) => disabled("sandbox (debug.sandbox is false)"),
158 Err(SandboxError::BadRequest(e)) => bad_request(e),
159 Err(SandboxError::UnknownPolicy(name)) => (
160 StatusCode::NOT_FOUND,
161 Json(serde_json::json!({"error": format!("unknown policy '{name}'")})),
162 )
163 .into_response(),
164 Err(SandboxError::Timeout(secs)) => (
165 StatusCode::GATEWAY_TIMEOUT,
166 Json(serde_json::json!({
167 "error": "sandbox_timeout",
168 "message": format!("run exceeded debug.sandbox_timeout_seconds ({}s)", secs),
169 })),
170 )
171 .into_response(),
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use crate::config::{DebugConfig, GatewayConfig, SystemConfig};
179 use crate::config_store::FileConfigStore;
180 use crate::debug::store::TraceSummary;
181 use crate::debug::TraceSource;
182
183 fn state_with(debug: DebugConfig) -> Arc<SharedState> {
184 let system = SystemConfig {
187 debug,
188 ..serde_yaml::from_str::<SystemConfig>("{}").unwrap()
189 };
190 let gateway: GatewayConfig = serde_yaml::from_str("{}").unwrap();
191 Arc::new(
192 SharedState::new(
193 system,
194 gateway,
195 None,
196 Arc::new(FileConfigStore::new(std::path::PathBuf::from(
197 "gateway.yaml",
198 ))),
199 )
200 .unwrap(),
201 )
202 }
203
204 #[test]
207 fn test_disabled_by_default() {
208 let s = state_with(DebugConfig::default());
209 assert!(!s.debug.enabled);
210 }
211
212 #[tokio::test]
213 async fn test_config_endpoint_answers_even_when_disabled() {
214 let s = state_with(DebugConfig::default());
215 let resp = get_config(State(s)).await.into_response();
216 assert_eq!(resp.status(), StatusCode::OK);
219 }
220
221 #[tokio::test]
222 async fn test_trace_routes_404_when_disabled() {
223 let s = state_with(DebugConfig::default());
224 assert_eq!(
225 list_traces(State(s.clone()), Query(TraceFilter::default()))
226 .await
227 .into_response()
228 .status(),
229 StatusCode::NOT_FOUND
230 );
231 assert_eq!(
232 get_trace(State(s.clone()), Path("any".to_string()))
233 .await
234 .into_response()
235 .status(),
236 StatusCode::NOT_FOUND
237 );
238 assert_eq!(
239 clear_traces(State(s.clone()))
240 .await
241 .into_response()
242 .status(),
243 StatusCode::NOT_FOUND
244 );
245 assert_eq!(
246 run_sandbox(State(s), Json(SandboxRequest::default()))
247 .await
248 .into_response()
249 .status(),
250 StatusCode::NOT_FOUND
251 );
252 }
253
254 #[tokio::test]
255 async fn test_sandbox_404s_when_only_sandbox_is_off() {
256 let s = state_with(DebugConfig {
257 enabled: true,
258 sandbox: false,
259 ..Default::default()
260 });
261 assert_eq!(
263 list_traces(State(s.clone()), Query(TraceFilter::default()))
264 .await
265 .into_response()
266 .status(),
267 StatusCode::OK
268 );
269 assert_eq!(
271 run_sandbox(State(s), Json(SandboxRequest::default()))
272 .await
273 .into_response()
274 .status(),
275 StatusCode::NOT_FOUND
276 );
277 }
278
279 #[tokio::test]
280 async fn test_sandbox_requires_exactly_one_mode() {
281 let s = state_with(DebugConfig {
282 enabled: true,
283 ..Default::default()
284 });
285 assert_eq!(
287 run_sandbox(State(s.clone()), Json(SandboxRequest::default()))
288 .await
289 .into_response()
290 .status(),
291 StatusCode::BAD_REQUEST
292 );
293 let both = SandboxRequest {
295 nodes: Some(Vec::new()),
296 policy: Some("p".to_string()),
297 ..Default::default()
298 };
299 assert_eq!(
300 run_sandbox(State(s), Json(both))
301 .await
302 .into_response()
303 .status(),
304 StatusCode::BAD_REQUEST
305 );
306 }
307
308 #[tokio::test]
309 async fn test_sandbox_unknown_policy_404s() {
310 let s = state_with(DebugConfig {
311 enabled: true,
312 ..Default::default()
313 });
314 let req = SandboxRequest {
315 policy: Some("nope".to_string()),
316 ..Default::default()
317 };
318 assert_eq!(
319 run_sandbox(State(s), Json(req))
320 .await
321 .into_response()
322 .status(),
323 StatusCode::NOT_FOUND
324 );
325 }
326
327 fn summary(route: &str, policy: &str, status: u16, source: TraceSource) -> TraceSummary {
328 TraceSummary {
329 id: format!("{route}-{status}"),
330 seq: 0,
331 source,
332 started_ms: 0,
333 route: Some(route.to_string()),
334 policy: policy.to_string(),
335 method: "GET".to_string(),
336 path: "/x".to_string(),
337 status,
338 duration_us: 1,
339 step_count: 1,
340 error_count: 0,
341 captured_bodies: false,
342 }
343 }
344
345 fn sample() -> Vec<TraceSummary> {
346 vec![
347 summary("echo-api", "echo-policy", 200, TraceSource::Request),
348 summary("secure-api", "secure-policy", 401, TraceSource::Request),
349 summary("echo-api", "echo-policy", 502, TraceSource::Request),
350 summary("s", "echo-policy", 200, TraceSource::Sandbox),
351 ]
352 }
353
354 fn ids(traces: Vec<TraceSummary>) -> Vec<String> {
355 traces.into_iter().map(|t| t.id).collect()
356 }
357
358 #[test]
359 fn test_no_filter_returns_all() {
360 assert_eq!(apply_filter(sample(), &TraceFilter::default()).len(), 4);
361 }
362
363 #[test]
364 fn test_filter_by_route() {
365 let f = TraceFilter {
366 route: Some("echo-api".to_string()),
367 ..Default::default()
368 };
369 assert_eq!(apply_filter(sample(), &f).len(), 2);
370 }
371
372 #[test]
373 fn test_filter_by_policy_spans_routes() {
374 let f = TraceFilter {
376 policy: Some("echo-policy".to_string()),
377 ..Default::default()
378 };
379 assert_eq!(apply_filter(sample(), &f).len(), 3);
380 }
381
382 #[test]
383 fn test_filters_are_anded() {
384 let f = TraceFilter {
385 policy: Some("echo-policy".to_string()),
386 status: Some(200),
387 source: Some("request".to_string()),
388 ..Default::default()
389 };
390 assert_eq!(ids(apply_filter(sample(), &f)), vec!["echo-api-200"]);
391 }
392
393 #[test]
394 fn test_empty_filter_string_is_ignored() {
395 let f = TraceFilter {
397 route: Some(" ".to_string()),
398 ..Default::default()
399 };
400 assert_eq!(apply_filter(sample(), &f).len(), 4);
401 }
402
403 #[test]
404 fn test_limit_applies_after_filtering() {
405 let f = TraceFilter {
406 policy: Some("echo-policy".to_string()),
407 limit: Some(2),
408 ..Default::default()
409 };
410 assert_eq!(apply_filter(sample(), &f).len(), 2);
411 }
412
413 #[test]
414 fn test_filter_by_source() {
415 let f = TraceFilter {
416 source: Some("sandbox".to_string()),
417 ..Default::default()
418 };
419 assert_eq!(ids(apply_filter(sample(), &f)), vec!["s-200"]);
420 }
421
422 #[tokio::test]
423 async fn test_sandbox_rejects_unknown_plugin_type() {
424 let s = state_with(DebugConfig {
425 enabled: true,
426 ..Default::default()
427 });
428 let req = SandboxRequest {
429 nodes: Some(vec![crate::config::NodeConfig {
430 id: "x".to_string(),
431 node_type: "no-such-plugin".to_string(),
432 config: Default::default(),
433 config_ref: None,
434 position: None,
435 }]),
436 ..Default::default()
437 };
438 assert_eq!(
439 run_sandbox(State(s), Json(req))
440 .await
441 .into_response()
442 .status(),
443 StatusCode::BAD_REQUEST
444 );
445 }
446
447 #[tokio::test]
450 async fn test_sandbox_expands_supernodes() {
451 let system = SystemConfig {
452 debug: DebugConfig {
453 enabled: true,
454 ..Default::default()
455 },
456 ..serde_yaml::from_str::<SystemConfig>("{}").unwrap()
457 };
458 let gateway: GatewayConfig = serde_yaml::from_str(
459 r#"
460supernodes:
461 - name: secured-call
462 nodes:
463 - { id: input, type: input }
464 - { id: output, type: output }
465 - { id: error, type: error }
466 - { id: c, type: cors }
467 edges:
468 - { from: input.out, to: c.in }
469 - { from: c.success, to: output.in }
470 - { from: c.preflight, to: output.in }
471policies:
472 - name: p
473 nodes:
474 - { id: listener, type: listener }
475 - { id: sec, type: supernode, config: { name: secured-call } }
476 - { id: client, type: client }
477 edges:
478 - { from: listener.out, to: sec.in }
479 - { from: sec.success, to: client.in }
480"#,
481 )
482 .unwrap();
483 let s = Arc::new(
484 SharedState::new(
485 system,
486 gateway,
487 None,
488 Arc::new(FileConfigStore::new(std::path::PathBuf::from(
489 "gateway.yaml",
490 ))),
491 )
492 .unwrap(),
493 );
494
495 let req = SandboxRequest {
496 policy: Some("p".to_string()),
497 ..Default::default()
498 };
499 let resp = run_sandbox(State(s), Json(req)).await.into_response();
500 assert_eq!(resp.status(), StatusCode::OK);
501
502 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
503 .await
504 .unwrap();
505 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
506 let steps = v["trace"]["steps"].as_array().unwrap();
507 assert!(
508 steps
509 .iter()
510 .any(|s| s["node_id"].as_str().unwrap_or("").starts_with("sec/")),
511 "expected a namespaced sec/* step, got: {v}"
512 );
513 }
514
515 #[tokio::test]
518 async fn test_sandbox_resolves_plugin_configs() {
519 let system = SystemConfig {
520 debug: DebugConfig {
521 enabled: true,
522 ..Default::default()
523 },
524 ..serde_yaml::from_str::<SystemConfig>("{}").unwrap()
525 };
526 let gateway: GatewayConfig = serde_yaml::from_str(
527 r#"
528plugin_configs:
529 - name: m
530 type: mocking
531 config: { response_status: 200, response_example: "from-shared", content_type: "text/plain" }
532policies:
533 - name: p
534 nodes:
535 - { id: listener, type: listener }
536 - { id: mock, type: mocking, config_ref: m }
537 - { id: client, type: client }
538 edges:
539 - { from: listener.out, to: mock.in }
540 - { from: mock.success, to: client.in }
541"#,
542 )
543 .unwrap();
544 let s = Arc::new(
545 SharedState::new(
546 system,
547 gateway,
548 None,
549 Arc::new(FileConfigStore::new(std::path::PathBuf::from(
550 "gateway.yaml",
551 ))),
552 )
553 .unwrap(),
554 );
555
556 let req = SandboxRequest {
557 policy: Some("p".to_string()),
558 ..Default::default()
559 };
560 let resp = run_sandbox(State(s), Json(req)).await.into_response();
561 assert_eq!(resp.status(), StatusCode::OK);
562 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
563 .await
564 .unwrap();
565 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
566 let steps = v["trace"]["steps"].as_array().unwrap();
567 assert!(
570 steps
571 .iter()
572 .any(|s| s["node_id"] == "mock" && s["outcome"]["kind"] == "success"),
573 "{v}"
574 );
575 }
576}