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