Skip to main content

running_process/broker/server/
admin.rs

1//! Admin verb rendering for the v1 broker.
2
3use std::io::{self, Read, Write};
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5
6use interprocess::local_socket::traits::Listener;
7use prost::Message;
8use serde::Serialize;
9use serde_json::json;
10
11use crate::broker::protocol::{
12    read_frame, write_frame, AdminReply, AdminReplyKind, AdminRequest, AdminVerb, Frame, FrameKind,
13    FramingError, PayloadEncoding, ENVELOPE_VERSION, MAX_FRAME_BYTES, MAX_HELLO_BYTES,
14    PROTOCOL_VERSION,
15};
16
17use super::backend_registry::BackendRegistry;
18use super::connection::{bind_local_socket, BrokerConnectionError, LocalSocketCleanup};
19use super::deadline_stream::{hello_read_deadline, with_nonblocking_deadline};
20use super::service_def_loader::{service_definition_dir, SERVICE_DEF_DIR_ENV};
21use super::spawn_coordinator::{
22    SpawnBudgetSnapshot, DEFAULT_SPAWN_ATTEMPTS_PER_WINDOW, DEFAULT_SPAWN_BUDGET_WINDOW,
23};
24use crate::broker::server::metrics::{MetricKind, BROKER_METRICS};
25
26/// Frozen admin JSON schema version.
27pub const ADMIN_SCHEMA_VERSION: u32 = 1;
28/// Payload protocol value for v1 admin request/reply frames.
29///
30/// Re-exported from the authoritative
31/// [`registry`](crate::broker::protocol::registry), which owns every v1
32/// payload-protocol ID (#375).
33pub use crate::broker::protocol::registry::ADMIN_PAYLOAD_PROTOCOL;
34
35const DIAGNOSTIC_BUNDLE_FORMAT: &str = "tar.gz";
36const DIAGNOSTIC_BUNDLE_MODE: &str = "metadata-only";
37const DIAGNOSTIC_REDACTIONS: &[&str] = &["home", "secret-env", "acl-identities"];
38
39/// Snapshot consumed by admin verb renderers.
40#[derive(Clone, Debug)]
41pub struct AdminSnapshot {
42    /// Broker instance identifier.
43    pub broker_instance: String,
44    /// Broker process id.
45    pub broker_pid: u32,
46    /// Snapshot generation timestamp.
47    pub generated_at_unix_ms: u64,
48    /// Time since broker start.
49    pub uptime: Duration,
50    /// Whether new Hello requests are accepted.
51    pub accepting_hello: bool,
52    /// Open control-plane connections.
53    pub connections_open: u64,
54    /// Known backend rows.
55    pub backends: Vec<AdminBackend>,
56    /// Known spawn budget rows.
57    pub spawn_budgets: Vec<AdminSpawnBudget>,
58    /// Whether the broker self-demoted under fd pressure (#390).
59    pub fd_pressure_demoted: bool,
60    /// Inode usage of the daemon data dir filesystem (#390).
61    pub inode_pressure: AdminInodePressure,
62}
63
64/// Inode usage row for `status --json` (#390). On Windows (and on Unix
65/// filesystems without a fixed inode table) `supported` is false and the
66/// counters are absent rather than faked.
67#[derive(Clone, Debug, Default)]
68pub struct AdminInodePressure {
69    /// Whether inode accounting applies to the probed filesystem.
70    pub supported: bool,
71    /// Probe failure detail, when the probe itself errored.
72    pub error: Option<String>,
73    /// Total inodes (only meaningful when `supported`).
74    pub total: u64,
75    /// Free inodes (only meaningful when `supported`).
76    pub free: u64,
77}
78
79impl AdminInodePressure {
80    /// Probe the daemon data dir filesystem.
81    pub fn probe() -> Self {
82        match crate::broker::fs_health::daemon_data_dir_inode_usage() {
83            Ok(Some(usage)) => Self {
84                supported: true,
85                error: None,
86                total: usage.total,
87                free: usage.free,
88            },
89            Ok(None) => Self::default(),
90            Err(err) => Self {
91                supported: false,
92                error: Some(err.to_string()),
93                total: 0,
94                free: 0,
95            },
96        }
97    }
98
99    fn to_json(&self) -> serde_json::Value {
100        if self.supported {
101            let usage = crate::broker::fs_health::InodeUsage {
102                total: self.total,
103                free: self.free,
104            };
105            json!({
106                "supported": true,
107                "inodes_total": self.total,
108                "inodes_free": self.free,
109                "inodes_used": usage.used(),
110                "used_ratio": usage.used_ratio(),
111            })
112        } else {
113            json!({
114                "supported": false,
115                "detail": self.error.clone().unwrap_or_else(|| {
116                    "inode accounting not applicable on this filesystem".into()
117                }),
118            })
119        }
120    }
121}
122
123impl AdminSnapshot {
124    /// Local process snapshot used until pipe-backed admin transport lands.
125    pub fn local_not_serving() -> Self {
126        Self {
127            broker_instance: "local".into(),
128            broker_pid: std::process::id(),
129            generated_at_unix_ms: unix_now_ms(),
130            uptime: Duration::ZERO,
131            accepting_hello: false,
132            connections_open: 0,
133            backends: Vec::new(),
134            spawn_budgets: Vec::new(),
135            fd_pressure_demoted: false,
136            inode_pressure: AdminInodePressure::probe(),
137        }
138    }
139
140    /// Build a live snapshot from broker state.
141    pub fn from_registry(
142        broker_instance: impl Into<String>,
143        uptime: Duration,
144        accepting_hello: bool,
145        connections_open: u64,
146        registry: &BackendRegistry,
147        spawn_budgets: &[SpawnBudgetSnapshot],
148    ) -> Self {
149        Self::from_registry_at(
150            broker_instance,
151            std::process::id(),
152            unix_now_ms(),
153            uptime,
154            accepting_hello,
155            connections_open,
156            registry,
157            spawn_budgets,
158        )
159    }
160
161    /// Testable variant of [`Self::from_registry`] with deterministic metadata.
162    #[allow(clippy::too_many_arguments)]
163    pub fn from_registry_at(
164        broker_instance: impl Into<String>,
165        broker_pid: u32,
166        generated_at_unix_ms: u64,
167        uptime: Duration,
168        accepting_hello: bool,
169        connections_open: u64,
170        registry: &BackendRegistry,
171        spawn_budgets: &[SpawnBudgetSnapshot],
172    ) -> Self {
173        Self {
174            broker_instance: broker_instance.into(),
175            broker_pid,
176            generated_at_unix_ms,
177            uptime,
178            accepting_hello,
179            connections_open,
180            backends: registry
181                .iter()
182                .map(|(_key, handle)| AdminBackend {
183                    service_name: handle.service_name.clone(),
184                    service_version: handle.service_version.clone(),
185                    pid: handle.daemon_process.pid,
186                    backend_pipe: handle.daemon_process.ipc_endpoint.path.clone(),
187                    last_active_unix_ms: handle.daemon_process.started_at_unix_ms,
188                    state: if handle.is_alive() {
189                        "running".into()
190                    } else {
191                        "stale".into()
192                    },
193                    last_hello_unix_ms: 0,
194                    last_error: None,
195                })
196                .collect(),
197            spawn_budgets: spawn_budgets
198                .iter()
199                .map(AdminSpawnBudget::from_snapshot)
200                .collect(),
201            fd_pressure_demoted: false,
202            inode_pressure: AdminInodePressure::probe(),
203        }
204    }
205
206    /// Record the broker's fd-pressure demotion state (#390).
207    pub fn with_fd_pressure_demoted(mut self, demoted: bool) -> Self {
208        self.fd_pressure_demoted = demoted;
209        self
210    }
211
212    /// Override the inode-pressure row (tests / deterministic snapshots).
213    pub fn with_inode_pressure(mut self, inode_pressure: AdminInodePressure) -> Self {
214        self.inode_pressure = inode_pressure;
215        self
216    }
217}
218
219/// Backend row used in admin output.
220#[derive(Clone, Debug)]
221pub struct AdminBackend {
222    /// Logical service name.
223    pub service_name: String,
224    /// Service version.
225    pub service_version: String,
226    /// Backend process id.
227    pub pid: u32,
228    /// Backend pipe/socket path.
229    pub backend_pipe: String,
230    /// Last activity timestamp.
231    pub last_active_unix_ms: u64,
232    /// Human-readable state.
233    pub state: String,
234    /// Last Hello timestamp.
235    pub last_hello_unix_ms: u64,
236    /// Last backend error.
237    pub last_error: Option<String>,
238}
239
240/// Spawn budget row used in admin output.
241#[derive(Clone, Debug)]
242pub struct AdminSpawnBudget {
243    /// Broker instance identifier.
244    pub broker_instance: String,
245    /// Logical service name.
246    pub service_name: String,
247    /// Service version.
248    pub service_version: String,
249    /// Attempts used in the active window.
250    pub attempts_used: u32,
251    /// Attempts remaining in the active window.
252    pub remaining: u32,
253    /// Whether a spawn is currently in flight.
254    pub in_flight: bool,
255    /// Retry-after hint when exhausted.
256    pub retry_after_ms: Option<u64>,
257}
258
259impl AdminSpawnBudget {
260    fn from_snapshot(snapshot: &SpawnBudgetSnapshot) -> Self {
261        Self {
262            broker_instance: snapshot.key.instance.id(),
263            service_name: snapshot.key.service_name.clone(),
264            service_version: snapshot.key.service_version.clone(),
265            attempts_used: snapshot.attempts_used,
266            remaining: snapshot.remaining,
267            in_flight: snapshot.in_flight,
268            retry_after_ms: snapshot
269                .retry_after
270                .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)),
271        }
272    }
273}
274
275/// Render `status --json`.
276pub fn render_status_json(snapshot: &AdminSnapshot) -> String {
277    json!({
278        "schema_version": ADMIN_SCHEMA_VERSION,
279        "command": "status",
280        "generated_at_unix_ms": snapshot.generated_at_unix_ms,
281        "broker_instance": snapshot.broker_instance,
282        "broker_pid": snapshot.broker_pid,
283        "uptime_seconds": snapshot.uptime.as_secs_f64(),
284        "accepting_hello": snapshot.accepting_hello,
285        "connections_open": snapshot.connections_open,
286        "fd_pressure": {
287            "demoted": snapshot.fd_pressure_demoted,
288        },
289        "inode_pressure": snapshot.inode_pressure.to_json(),
290        "backends": snapshot.backends.iter().map(|backend| {
291            json!({
292                "service_name": backend.service_name,
293                "service_version": backend.service_version,
294                "pid": backend.pid,
295                "backend_pipe": backend.backend_pipe,
296                "last_active_unix_ms": backend.last_active_unix_ms,
297                "state": backend.state,
298            })
299        }).collect::<Vec<_>>(),
300    })
301    .to_string()
302}
303
304/// Render `dump --json`.
305pub fn render_dump_json(snapshot: &AdminSnapshot) -> String {
306    json!({
307        "schema_version": ADMIN_SCHEMA_VERSION,
308        "command": "dump",
309        "generated_at_unix_ms": snapshot.generated_at_unix_ms,
310        "broker_instance": snapshot.broker_instance,
311        "effective_config": effective_config_json(snapshot),
312        "backend_table": snapshot.backends.iter().map(|backend| {
313            json!({
314                "service_name": backend.service_name,
315                "service_version": backend.service_version,
316                "pid": backend.pid,
317                "backend_pipe": backend.backend_pipe,
318                "state": backend.state,
319            })
320        }).collect::<Vec<_>>(),
321        "spawn_budgets": snapshot.spawn_budgets.iter().map(|budget| {
322            json!({
323                "broker_instance": budget.broker_instance,
324                "service_name": budget.service_name,
325                "service_version": budget.service_version,
326                "attempts_used": budget.attempts_used,
327                "remaining": budget.remaining,
328                "in_flight": budget.in_flight,
329                "retry_after_ms": budget.retry_after_ms,
330            })
331        }).collect::<Vec<_>>(),
332        "recent_lifecycle_events": [],
333    })
334    .to_string()
335}
336
337/// Render `list-instances --json`.
338pub fn render_list_instances_json(snapshot: &AdminSnapshot) -> String {
339    json!({
340        "schema_version": ADMIN_SCHEMA_VERSION,
341        "command": "list-instances",
342        "generated_at_unix_ms": snapshot.generated_at_unix_ms,
343        "instances": [{
344            "broker_instance": snapshot.broker_instance,
345            "pipe": "",
346            "pid": snapshot.broker_pid,
347            "state": if snapshot.accepting_hello { "running" } else { "not-serving" },
348        }],
349    })
350    .to_string()
351}
352
353/// Render `backend-health <service> --json`.
354pub fn render_backend_health_json(snapshot: &AdminSnapshot, service_name: &str) -> String {
355    json!({
356        "schema_version": ADMIN_SCHEMA_VERSION,
357        "command": "backend-health",
358        "generated_at_unix_ms": snapshot.generated_at_unix_ms,
359        "service_name": service_name,
360        "backends": snapshot.backends.iter()
361            .filter(|backend| backend.service_name == service_name)
362            .map(|backend| {
363                json!({
364                    "service_version": backend.service_version,
365                    "pid": backend.pid,
366                    "state": backend.state,
367                    "last_hello_unix_ms": backend.last_hello_unix_ms,
368                    "last_error": backend.last_error,
369                })
370            })
371            .collect::<Vec<_>>(),
372    })
373    .to_string()
374}
375
376/// Render `config --effective --json`.
377pub fn render_config_json(snapshot: &AdminSnapshot) -> String {
378    json!({
379        "schema_version": ADMIN_SCHEMA_VERSION,
380        "command": "config",
381        "generated_at_unix_ms": snapshot.generated_at_unix_ms,
382        "values": effective_config_json(snapshot),
383    })
384    .to_string()
385}
386
387/// Render `diagnose --output <path>` summary JSON.
388pub fn render_diagnose_json(snapshot: &AdminSnapshot, output: &str) -> String {
389    let entries = diagnostic_bundle_entries_json(snapshot);
390    json!({
391        "schema_version": ADMIN_SCHEMA_VERSION,
392        "command": "diagnose",
393        "generated_at_unix_ms": snapshot.generated_at_unix_ms,
394        "output": output,
395        "bundle": {
396            "format": DIAGNOSTIC_BUNDLE_FORMAT,
397            "mode": DIAGNOSTIC_BUNDLE_MODE,
398            "created": false,
399            "entries": entries,
400        },
401        "files": diagnostic_bundle_file_paths(snapshot),
402        "redactions": diagnostic_redaction_names(),
403        "redaction_policy": diagnostic_redaction_policy_json(),
404    })
405    .to_string()
406}
407
408/// Render OpenMetrics text.
409pub fn render_metrics_text(snapshot: &AdminSnapshot) -> String {
410    let mut out = String::new();
411    for metric in BROKER_METRICS {
412        out.push_str("# TYPE ");
413        out.push_str(metric.name);
414        out.push(' ');
415        out.push_str(metric_kind_name(metric.kind));
416        out.push('\n');
417        if metric.labels.is_empty() {
418            out.push_str(metric.name);
419            out.push(' ');
420            out.push_str(&metric_value(metric.name, snapshot));
421            out.push('\n');
422        }
423    }
424    out.push_str("# EOF\n");
425    out
426}
427
428/// Health endpoint body.
429pub fn render_healthz() -> &'static str {
430    "ok\n"
431}
432
433/// Readiness endpoint body.
434pub fn render_readyz(snapshot: &AdminSnapshot) -> &'static str {
435    if snapshot.accepting_hello {
436        "ready\n"
437    } else {
438        "not ready\n"
439    }
440}
441
442/// Render one typed admin request into a typed admin reply.
443pub fn render_admin_reply(snapshot: &AdminSnapshot, request: &AdminRequest) -> AdminReply {
444    match AdminVerb::try_from(request.verb) {
445        Ok(AdminVerb::Status) => {
446            if request.json {
447                json_reply(render_status_json(snapshot))
448            } else {
449                text_reply(
450                    format!(
451                        "broker_instance: {}\naccepting_hello: {}\n",
452                        snapshot.broker_instance, snapshot.accepting_hello
453                    ),
454                    0,
455                )
456            }
457        }
458        Ok(AdminVerb::Dump) => json_reply(render_dump_json(snapshot)),
459        Ok(AdminVerb::ListInstances) => json_reply(render_list_instances_json(snapshot)),
460        Ok(AdminVerb::Healthz) => text_reply(render_healthz(), 0),
461        Ok(AdminVerb::Readyz) => {
462            let exit_code = if snapshot.accepting_hello { 0 } else { 1 };
463            text_reply(render_readyz(snapshot), exit_code)
464        }
465        Ok(AdminVerb::BackendHealth) => {
466            let service_name = if request.service_name.is_empty() {
467                "unknown"
468            } else {
469                &request.service_name
470            };
471            json_reply(render_backend_health_json(snapshot, service_name))
472        }
473        Ok(AdminVerb::Config) => json_reply(render_config_json(snapshot)),
474        Ok(AdminVerb::Diagnose) => {
475            let output = if request.output_path.is_empty() {
476                "bundle.tar.gz"
477            } else {
478                &request.output_path
479            };
480            json_reply(render_diagnose_json(snapshot, output))
481        }
482        Ok(AdminVerb::Metrics) => AdminReply {
483            kind: AdminReplyKind::Openmetrics as i32,
484            body: render_metrics_text(snapshot),
485            exit_code: 0,
486            content_type: "application/openmetrics-text".into(),
487        },
488        Ok(AdminVerb::Unspecified) | Err(_) => text_reply("unsupported admin verb\n", 2),
489    }
490}
491
492/// Handle one decoded admin frame and return a response frame.
493pub fn handle_admin_frame(
494    frame: Frame,
495    snapshot: &AdminSnapshot,
496) -> Result<Frame, AdminFrameError> {
497    if frame.envelope_version != PROTOCOL_VERSION {
498        return Err(AdminFrameError::UnsupportedEnvelopeVersion(
499            frame.envelope_version,
500        ));
501    }
502    if FrameKind::try_from(frame.kind) != Ok(FrameKind::Request) {
503        return Err(AdminFrameError::UnexpectedKind(frame.kind));
504    }
505    if frame.payload_protocol != ADMIN_PAYLOAD_PROTOCOL {
506        return Err(AdminFrameError::UnexpectedPayloadProtocol(
507            frame.payload_protocol,
508        ));
509    }
510    if PayloadEncoding::try_from(frame.payload_encoding) != Ok(PayloadEncoding::None) {
511        return Err(AdminFrameError::UnsupportedPayloadEncoding(
512            frame.payload_encoding,
513        ));
514    }
515
516    let request =
517        AdminRequest::decode(frame.payload.as_slice()).map_err(AdminFrameError::Decode)?;
518    let reply = render_admin_reply(snapshot, &request);
519    Ok(Frame {
520        envelope_version: PROTOCOL_VERSION,
521        kind: FrameKind::Response as i32,
522        payload_protocol: ADMIN_PAYLOAD_PROTOCOL,
523        payload: reply.encode_to_vec(),
524        request_id: frame.request_id,
525        payload_encoding: PayloadEncoding::None as i32,
526        deadline_unix_ms: 0,
527        traceparent: frame.traceparent,
528        tracestate: frame.tracestate,
529    })
530}
531
532/// Handle one already-accepted broker admin connection.
533///
534/// The connection reads one v1-framed [`Frame`] carrying an [`AdminRequest`],
535/// dispatches through [`handle_admin_frame`], writes one v1-framed response
536/// [`Frame`] carrying an [`AdminReply`], then returns the decoded reply for
537/// tests and callers that need exit-code metadata.
538pub fn handle_admin_connection<S: Read + Write>(
539    stream: &mut S,
540    snapshot: &AdminSnapshot,
541) -> Result<AdminReply, AdminConnectionError> {
542    let request_bytes = read_frame(stream)?;
543    let request_frame =
544        Frame::decode(request_bytes.as_slice()).map_err(AdminConnectionError::DecodeFrame)?;
545    let response_frame = handle_admin_frame(request_frame, snapshot)?;
546    write_frame(stream, &response_frame.encode_to_vec())?;
547    AdminReply::decode(response_frame.payload.as_slice()).map_err(AdminConnectionError::DecodeReply)
548}
549
550/// Run one blocking local-socket accept and serve exactly one admin request.
551///
552/// This is the admin-side counterpart to `serve_one_local_socket` for Hello.
553/// The full long-lived broker loop can reuse [`handle_admin_connection`] after
554/// selecting an admin connection from the shared accept path.
555pub fn serve_one_admin_socket(
556    socket_path: &str,
557    snapshot: &AdminSnapshot,
558) -> Result<AdminReply, AdminConnectionError> {
559    let listener = bind_local_socket(socket_path)?;
560    let cleanup = LocalSocketCleanup(socket_path);
561    let result = (|| {
562        let mut stream = listener.accept()?;
563        with_nonblocking_deadline(&mut stream, hello_read_deadline(), |stream| {
564            handle_admin_connection(stream, snapshot)
565        })
566    })();
567    drop(listener);
568    drop(cleanup);
569    result
570}
571
572/// Errors raised by admin frame validation/dispatch.
573#[derive(Debug, thiserror::Error)]
574pub enum AdminFrameError {
575    /// Unsupported frame envelope version.
576    #[error("unsupported admin frame envelope_version {0}")]
577    UnsupportedEnvelopeVersion(u32),
578    /// Admin frames must be requests.
579    #[error("admin frame kind must be REQUEST, got {0}")]
580    UnexpectedKind(i32),
581    /// Admin frame used the wrong payload protocol.
582    #[error("admin frame payload_protocol must be 0xAD01, got {0}")]
583    UnexpectedPayloadProtocol(u32),
584    /// Admin frame payload must be uncompressed.
585    #[error("admin frame payload must not be compressed, got {0}")]
586    UnsupportedPayloadEncoding(i32),
587    /// AdminRequest protobuf decoding failed.
588    #[error(transparent)]
589    Decode(prost::DecodeError),
590}
591
592/// Errors raised while serving a framed admin connection.
593#[derive(Debug, thiserror::Error)]
594pub enum AdminConnectionError {
595    /// v1 framing failed.
596    #[error(transparent)]
597    Framing(#[from] FramingError),
598    /// The request frame could not be decoded.
599    #[error("failed to decode admin request Frame: {0}")]
600    DecodeFrame(prost::DecodeError),
601    /// The request frame failed admin validation or dispatch.
602    #[error(transparent)]
603    AdminFrame(#[from] AdminFrameError),
604    /// The response payload could not be decoded after dispatch.
605    #[error("failed to decode admin reply payload: {0}")]
606    DecodeReply(prost::DecodeError),
607    /// Local socket binding failed.
608    #[error(transparent)]
609    LocalSocket(#[from] BrokerConnectionError),
610    /// Local socket I/O failed.
611    #[error(transparent)]
612    Io(#[from] io::Error),
613}
614
615fn json_reply(body: String) -> AdminReply {
616    AdminReply {
617        kind: AdminReplyKind::Json as i32,
618        body,
619        exit_code: 0,
620        content_type: "application/json".into(),
621    }
622}
623
624fn text_reply(body: impl Into<String>, exit_code: u32) -> AdminReply {
625    AdminReply {
626        kind: AdminReplyKind::Text as i32,
627        body: body.into(),
628        exit_code,
629        content_type: "text/plain".into(),
630    }
631}
632
633fn metric_kind_name(kind: MetricKind) -> &'static str {
634    match kind {
635        MetricKind::Counter => "counter",
636        MetricKind::Gauge => "gauge",
637        MetricKind::Histogram => "histogram",
638    }
639}
640
641fn metric_value(name: &str, snapshot: &AdminSnapshot) -> String {
642    match name {
643        "running_process_broker_v1_connections_open" => snapshot.connections_open.to_string(),
644        "running_process_broker_v1_fd_usage_ratio" => "0".into(),
645        "running_process_broker_v1_uptime_seconds" => snapshot.uptime.as_secs().to_string(),
646        _ => "0".into(),
647    }
648}
649
650fn effective_config_json(snapshot: &AdminSnapshot) -> serde_json::Value {
651    json!({
652        "broker": {
653            "broker_instance": sourced_value(&snapshot.broker_instance, "runtime"),
654            "broker_pid": sourced_value(snapshot.broker_pid, "runtime"),
655            "accepting_hello": sourced_value(snapshot.accepting_hello, "runtime"),
656        },
657        "protocol": {
658            "admin_payload_protocol": sourced_value(format!("0x{ADMIN_PAYLOAD_PROTOCOL:04X}"), "protocol-v1"),
659            "envelope_version": sourced_value(PROTOCOL_VERSION, "protocol-v1"),
660            "framing_version": sourced_value(ENVELOPE_VERSION, "protocol-v1"),
661        },
662        "limits": {
663            "max_frame_bytes": sourced_value(MAX_FRAME_BYTES, "protocol-v1"),
664            "max_hello_bytes": sourced_value(MAX_HELLO_BYTES, "protocol-v1"),
665            "connections_open": sourced_value(snapshot.connections_open, "runtime"),
666        },
667        "paths": {
668            "service_definition_dir": sourced_value(
669                service_definition_dir().display().to_string(),
670                service_definition_dir_source(),
671            ),
672        },
673        "spawn_budget": {
674            "default_attempts_per_window": sourced_value(DEFAULT_SPAWN_ATTEMPTS_PER_WINDOW, "default"),
675            "default_window_ms": sourced_value(duration_ms(DEFAULT_SPAWN_BUDGET_WINDOW), "default"),
676            "active_budget_rows": sourced_value(snapshot.spawn_budgets.len(), "runtime"),
677        },
678        "diagnostics": {
679            "bundle_format": sourced_value(DIAGNOSTIC_BUNDLE_FORMAT, "schema-v1"),
680            "bundle_mode": sourced_value(DIAGNOSTIC_BUNDLE_MODE, "schema-v1"),
681            "redactions": sourced_value(diagnostic_redaction_names(), "schema-v1"),
682        },
683    })
684}
685
686fn service_definition_dir_source() -> &'static str {
687    if std::env::var_os(SERVICE_DEF_DIR_ENV).is_some() {
688        "env:RUNNING_PROCESS_SERVICE_DEF_DIR"
689    } else {
690        "platform-default"
691    }
692}
693
694fn diagnostic_bundle_entries_json(snapshot: &AdminSnapshot) -> Vec<serde_json::Value> {
695    vec![
696        diagnostic_bundle_entry("admin/status.json", "json", "status", true, false, None),
697        diagnostic_bundle_entry("admin/dump.json", "json", "dump", true, true, None),
698        diagnostic_bundle_entry(
699            "config/effective.json",
700            "json",
701            "effective-config",
702            true,
703            false,
704            None,
705        ),
706        diagnostic_bundle_entry(
707            "metrics/openmetrics.txt",
708            "openmetrics",
709            "metrics",
710            true,
711            false,
712            None,
713        ),
714        diagnostic_bundle_entry(
715            "events/lifecycle.jsonl",
716            "jsonl",
717            "lifecycle-events",
718            false,
719            true,
720            None,
721        ),
722        diagnostic_bundle_entry(
723            "manifest/backend-manifests.json",
724            "json",
725            "backend-manifest-index",
726            false,
727            true,
728            None,
729        ),
730        diagnostic_bundle_entry(
731            "process/backends.json",
732            "json",
733            "backend-table",
734            true,
735            true,
736            Some(snapshot.backends.len()),
737        ),
738        diagnostic_bundle_entry(
739            "system/summary.json",
740            "json",
741            "host-summary",
742            false,
743            true,
744            None,
745        ),
746    ]
747}
748
749fn diagnostic_bundle_file_paths(snapshot: &AdminSnapshot) -> Vec<String> {
750    diagnostic_bundle_entries_json(snapshot)
751        .into_iter()
752        .filter_map(|entry| {
753            entry
754                .get("path")
755                .and_then(serde_json::Value::as_str)
756                .map(str::to_owned)
757        })
758        .collect()
759}
760
761fn diagnostic_bundle_entry(
762    path: &str,
763    kind: &str,
764    source: &str,
765    required: bool,
766    redacted: bool,
767    record_count: Option<usize>,
768) -> serde_json::Value {
769    let mut entry = json!({
770        "path": path,
771        "kind": kind,
772        "source": source,
773        "required": required,
774        "redacted": redacted,
775    });
776    if let Some(record_count) = record_count {
777        entry["record_count"] = json!(record_count);
778    }
779    entry
780}
781
782fn diagnostic_redaction_names() -> Vec<&'static str> {
783    DIAGNOSTIC_REDACTIONS.to_vec()
784}
785
786fn diagnostic_redaction_policy_json() -> Vec<serde_json::Value> {
787    vec![
788        json!({
789            "name": "home",
790            "replacement": "~",
791        }),
792        json!({
793            "name": "secret-env",
794            "matches": ["KEY", "TOKEN", "SECRET", "PASS"],
795        }),
796        json!({
797            "name": "acl-identities",
798            "replacement": "stable-hash",
799        }),
800    ]
801}
802
803fn sourced_value(value: impl Serialize, source: &'static str) -> serde_json::Value {
804    json!({
805        "value": value,
806        "source": source,
807    })
808}
809
810fn duration_ms(duration: Duration) -> u64 {
811    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
812}
813
814fn unix_now_ms() -> u64 {
815    SystemTime::now()
816        .duration_since(UNIX_EPOCH)
817        .map(|duration| duration.as_millis() as u64)
818        .unwrap_or(0)
819}