trusty_console/webhook/spawn.rs
1//! Starting a relay target that is not running (#5182, ADR-0034 §1).
2//!
3//! Why: milestone `tm 1.3.5` criterion (c) wants no resident `trusty-analyze`
4//! or `trusty-review` daemon, and relaying over UDS wants a bound listener.
5//! Both hold only if something makes the target resident at the moment of
6//! delivery. Measured, that trade is cheap: #5028 recorded zero webhook
7//! deliveries in 14 days, a 191 ms cold start, and a 36.7 s median review — so
8//! the spawn is half a percent of the work it precedes.
9//!
10//! What: a thin wrapper over `trusty_common::uds::UdsServiceSupervisor`, the
11//! supervisor promoted from `trusty-memory`'s `Bm25Supervisor` in #5089 step 2.
12//! Writing a second supervisor here would re-earn the scars of #2845, #2846 and
13//! #5085 — the serialised spawn gate, the live-child cap, the socket-decides-
14//! liveness rule — so this only supplies the per-service parts: which binary,
15//! which argv, and the timing budget.
16//!
17//! Test: `webhook/tests.rs` — `spawn_*`.
18
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21use std::time::Duration;
22
23use trusty_common::uds::{
24 ServiceTimeouts, SpawnSpec, SupervisorConfig, SupervisorError, UdsServiceSupervisor,
25};
26use trusty_common::webhook_relay::LISTENER_SHUTDOWN_FLUSH;
27
28/// Subcommand every relay target implements to serve its socket.
29pub const LISTEN_SUBCOMMAND: &str = "webhook-listen";
30
31/// Environment variable that hands target lifecycle back to the operator.
32///
33/// Set to exactly `"1"` when running the targets under `tctl` (ADR-0011): the
34/// supervisor then dials whatever is at the socket and never spawns.
35pub const ENV_EXTERNAL_TARGETS: &str = "TRUSTY_WEBHOOK_TARGET_EXTERNAL";
36
37/// How long a freshly-spawned target has to bind and accept.
38///
39/// Generous rather than tight: `trusty-review` cold-starts in 191 ms (#5028)
40/// but a first run after an upgrade pays page-in costs on a much larger binary,
41/// and a spawn that times out looks to an operator like a broken install.
42const SPAWN_PROBE_TIMEOUT: Duration = Duration::from_secs(20);
43
44/// SIGTERM-to-SIGKILL patience, strictly above the listener's own flush budget.
45///
46/// 🔴 This `const` item is the compile-time guard. `ServiceTimeouts::new` is a
47/// `const fn` that asserts `sigterm_patience > shutdown_flush`, so lowering this
48/// to or below [`LISTENER_SHUTDOWN_FLUSH`] fails the build rather than shipping
49/// a SIGKILL that lands inside a delivery the child is mid-way through.
50const SIGTERM_PATIENCE: Duration = Duration::from_secs(5);
51
52/// The targets' timing budget.
53///
54/// `shutdown_flush` is the listener's OWN constant, imported from the contract
55/// module both halves share — not a literal that happens to match it today.
56/// Console cannot depend on `trusty-review` or `trusty-analyze`, so that shared
57/// module is where the number has to live for the sourcing rule on
58/// [`ServiceTimeouts`] to be satisfiable at all.
59const TARGET_TIMEOUTS: ServiceTimeouts = ServiceTimeouts::new(
60 SPAWN_PROBE_TIMEOUT,
61 LISTENER_SHUTDOWN_FLUSH,
62 SIGTERM_PATIENCE,
63);
64
65/// Supervises the two webhook relay targets on demand.
66///
67/// Why: one supervisor across both services rather than one each, so the
68/// live-child cap is a statement about console's whole child population.
69/// What: `ensure_running` keyed by source (`review` / `analyze`), with the
70/// binary located lazily so an already-running or externally-managed target
71/// never requires it to be installed.
72/// Test: `spawn_adopts_a_socket_that_is_already_served`,
73/// `spawn_maps_each_source_to_its_binary`. The external-mode opt-out itself is
74/// `trusty-common`'s `external_env_only_honours_exactly_one` — console supplies
75/// only the variable name, and a test here would have to mutate a
76/// process-global env var that every sibling in the binary can see.
77#[derive(Debug)]
78pub struct TargetSupervisor {
79 inner: UdsServiceSupervisor,
80}
81
82impl TargetSupervisor {
83 /// Build a supervisor for the relay targets.
84 ///
85 /// `max_live` is 2 — there are exactly two targets, and a cap that reaped
86 /// one to make room for the other would thrash under a two-source burst.
87 pub fn new() -> Self {
88 Self {
89 inner: UdsServiceSupervisor::new(
90 SupervisorConfig::new("trusty-webhook-target", 2, TARGET_TIMEOUTS)
91 .with_external_env(ENV_EXTERNAL_TARGETS),
92 ),
93 }
94 }
95
96 /// How many children this supervisor has launched.
97 pub fn spawned_count(&self) -> u64 {
98 self.inner.spawned_count()
99 }
100
101 /// Ensure something is serving `socket` for `source`.
102 ///
103 /// Why: called immediately before a relay, so the socket is bound by the
104 /// time the frame is written. The supervisor's own fast path means a target
105 /// already serving costs one probe, not a spawn.
106 ///
107 /// # Errors
108 ///
109 /// [`SupervisorError`] when the binary cannot be found, the spawn fails, or
110 /// the child never binds. Every one leaves the delivery unrelayed and
111 /// therefore unacked — the caller records it as `Unreachable`, which keeps
112 /// the spool entry.
113 ///
114 /// Test: `spawn_adopts_a_socket_that_is_already_served` covers the path
115 /// that resolves no binary; the spawn path itself is covered by
116 /// `trusty-common`'s supervisor suite rather than by launching a real
117 /// `trusty-review` from a unit test.
118 pub async fn ensure_running(
119 &self,
120 source: &str,
121 socket: &Path,
122 ) -> Result<PathBuf, SupervisorError> {
123 let binary = target_binary_name(source).to_string();
124 self.inner
125 .ensure_running(source, socket, move || {
126 let program = trusty_common::bin_resolve::resolve_binary(&binary).ok_or_else(
127 || -> Box<dyn std::error::Error + Send + Sync> {
128 format!(
129 "{binary} is not installed or not on PATH; \
130 console cannot start the webhook target"
131 )
132 .into()
133 },
134 )?;
135 Ok(SpawnSpec::new(program).arg(LISTEN_SUBCOMMAND))
136 })
137 .await
138 }
139
140 /// SIGTERM every child and clean up its socket.
141 pub async fn shutdown(&self) {
142 self.inner.shutdown().await;
143 }
144}
145
146impl Default for TargetSupervisor {
147 fn default() -> Self {
148 Self::new()
149 }
150}
151
152/// The binary that serves a given `{source}` route segment.
153///
154/// Test: `spawn_maps_each_source_to_its_binary`.
155pub fn target_binary_name(source: &str) -> &'static str {
156 match source {
157 trusty_common::webhook_relay::ANALYZE_SOURCE => "trusty-analyze",
158 // `review` is the only other configured source; anything else never
159 // reaches here, because `ingest` rejects an unknown source with a 404
160 // before a relay is selected.
161 _ => "trusty-review",
162 }
163}
164
165/// Shared handle a [`super::relay::UdsRelay`] holds.
166pub type SharedSupervisor = Arc<TargetSupervisor>;