Skip to main content

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>;