Skip to main content

ssh_cli/json_wire/
exec_target.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// GAP-SSH-EXEC-ENVELOPE-002: target identity and its process-wide slot.
3#![forbid(unsafe_code)]
4//! Which host an execution ran against, and how that host was chosen.
5//!
6//! # Why this is not in `execution.rs`
7//!
8//! That module is a set of serialization DTOs: inert structs that describe a result.
9//! What lives here is different in kind — a mutable slot holding a decision this
10//! process made, written once and read at the failure seam. Mixing a piece of
11//! process state into a file of wire types makes the file harder to reason about
12//! than either half alone, and pushed it past the component budget besides.
13
14use serde::{Deserialize, Serialize};
15
16/// Where the executing host came from, reported by `ExecutionJson::host_source`.
17///
18/// GAP-SSH-EXEC-ENVELOPE-002: the single-host envelope named no host at all, so a
19/// caller reading stdout could not tell a host typed in argv from one inherited from
20/// the on-disk active marker. Those two are the same bytes on the wire and wildly
21/// different in blast radius, and the distinction is exactly what an agent needs
22/// before it writes a systemd unit.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
24#[serde(rename_all = "snake_case")]
25pub enum TargetSource {
26    /// The host name was supplied as a positional in this invocation.
27    #[default]
28    Argv,
29    /// The host came from the on-disk active marker under an explicit `--use-active`.
30    ActiveMarker,
31    /// The host set came from `--all`, `--hosts` or `--tags`.
32    Selector,
33}
34
35impl TargetSource {
36    /// Wire spelling, kept in one place so text and JSON never drift apart.
37    #[must_use]
38    pub fn as_str(self) -> &'static str {
39        match self {
40            Self::Argv => "argv",
41            Self::ActiveMarker => "active_marker",
42            Self::Selector => "selector",
43        }
44    }
45
46    /// Whether the host was inherited rather than designated in this invocation.
47    #[must_use]
48    pub fn is_ambient(self) -> bool {
49        matches!(self, Self::ActiveMarker)
50    }
51}
52
53/// Identity of the host a single-host execution actually ran against.
54///
55/// Carried separately from [`crate::ssh::ExecutionOutput`] because the output knows
56/// what happened and not where: the host is decided during argv parsing, long before
57/// any channel is opened.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct ExecTarget {
60    /// Resolved VPS name.
61    pub host: String,
62    /// How that name was obtained.
63    pub source: TargetSource,
64}
65
66impl ExecTarget {
67    /// Builds a target from a resolved name and its provenance.
68    #[must_use]
69    pub fn new(host: impl Into<String>, source: TargetSource) -> Self {
70        Self {
71            host: host.into(),
72            source,
73        }
74    }
75}
76
77/// The resolved target, echoed under both the canonical and the legacy names.
78///
79/// # Why two spellings of one fact
80///
81/// Explicit Target Designation names these fields `target_resolved` / `target_source`,
82/// and the 0.5.5 release shipped them as `host_resolved` / `host_source` before the law
83/// was written down. Renaming in place would break every consumer that already reads
84/// the 0.5.5 spelling — including the schemas under `docs/schemas/`, which list
85/// `host_resolved` in their `required` arrays. Emitting both is the only move that
86/// makes the canonical name available without invalidating a contract already in the
87/// field: `target_*` is what new readers should bind to, `host_*` is a read alias kept
88/// for compatibility and never a second source of truth. They are written from the
89/// same [`ExecTarget`] in one place, so they cannot drift.
90///
91/// Flattened rather than nested: the alias only helps if it appears at the same depth
92/// as the field it replaces.
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
94pub struct TargetEcho {
95    /// Canonical name of the host this invocation resolved.
96    #[serde(default)]
97    pub target_resolved: String,
98    /// Canonical provenance of [`Self::target_resolved`].
99    #[serde(default)]
100    pub target_source: TargetSource,
101    /// Compatibility alias of [`Self::target_resolved`] (0.5.5 spelling).
102    #[serde(default)]
103    pub host_resolved: String,
104    /// Compatibility alias of [`Self::target_source`] (0.5.5 spelling).
105    #[serde(default)]
106    pub host_source: TargetSource,
107}
108
109impl TargetEcho {
110    /// Builds the echo from a resolved target, filling canonical and alias alike.
111    #[must_use]
112    pub fn new(target: &ExecTarget) -> Self {
113        Self {
114            target_resolved: target.host.clone(),
115            target_source: target.source,
116            host_resolved: target.host.clone(),
117            host_source: target.source,
118        }
119    }
120
121    /// Builds the echo from a name plus a provenance already decided by the caller.
122    #[must_use]
123    pub fn from_parts(host: impl Into<String>, source: TargetSource) -> Self {
124        Self::new(&ExecTarget::new(host, source))
125    }
126}
127
128/// Target resolved by *this* process, readable from the top-level error handler.
129///
130/// # Why this is process state and not a field on the error
131///
132/// GAP-SSH-EXEC-ENVELOPE-002 asks for the audit fields on the error path too, and
133/// that is where the information matters most: the failing step is the moment a
134/// caller most needs to know *where* it failed. The obvious shape — a field on
135/// [`crate::errors::SshCliError`] — cannot work here. `resolve_exit_code` recovers
136/// errors by `downcast_ref` and *rebuilds* `SshCliError` from `DomainError` and
137/// `std::io::Error`, so a per-variant field is discarded on exactly the paths that
138/// need it. Threading the target through instead would add an eighth parameter to
139/// `print_error_envelope`, which already sits at the `clippy::too_many_arguments`
140/// ceiling that `gaps_v062_component_budget` caps at two crate-wide allows.
141///
142/// The host is not a property of the failure; it is a property of the *process*.
143/// This is the same shape `agent_shape::SHAPE` already uses for output shaping, for
144/// the same reason: one write after argv resolution, one read at the emission seam.
145static RESOLVED_TARGET: std::sync::Mutex<Option<ExecTarget>> = std::sync::Mutex::new(None);
146
147/// Locks the slot, recovering from poisoning rather than aborting the run.
148///
149/// A poisoned mutex here means another thread panicked while holding it. In a
150/// one-shot CLI the correct response is to keep reporting: losing the audit field is
151/// bad, losing the error envelope entirely is worse.
152fn lock_target() -> std::sync::MutexGuard<'static, Option<ExecTarget>> {
153    RESOLVED_TARGET.lock().unwrap_or_else(|poisoned| {
154        tracing::warn!("resolved-target mutex was poisoned; recovering (one-shot CLI)");
155        poisoned.into_inner()
156    })
157}
158
159/// Records the host this process resolved, so failures can name it.
160///
161/// Call this *after* the registry lookup succeeds. Setting it earlier would make a
162/// `VpsNotFound` envelope claim a resolved host that was never resolved.
163pub fn set_resolved_target(target: &ExecTarget) {
164    *lock_target() = Some(target.clone());
165}
166
167/// Returns the host resolved by this process, if any.
168///
169/// [`None`] is meaningful: the failure happened before a host was chosen, so there
170/// is nothing to report. Emitting an empty string instead would be an assertion
171/// about the target rather than an admission of not having one.
172#[must_use]
173pub fn resolved_target() -> Option<ExecTarget> {
174    lock_target().clone()
175}
176
177// Deliberately no test-only reset helper. `serial_test` only serializes serial tests
178// against each other, so a non-serial test still runs concurrently with them: an
179// in-process assertion on this slot could observe another test's write. The error
180// envelope is therefore proven end-to-end in a separate process
181// (`tests/gaps_v065_exec_target_designation.rs`), where the one-shot lifecycle makes
182// the slot unambiguous by construction.
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    /// The canonical `target_*` names ship alongside the 0.5.5 `host_*` aliases.
189    ///
190    /// Both spellings must appear at the *same depth* once flattened — a nested object
191    /// would satisfy the struct and break every consumer that reads `.host_resolved`
192    /// off the top level, which is the only reason the alias exists.
193    #[test]
194    fn the_canonical_and_alias_spellings_travel_together() {
195        let echo = TargetEcho::from_parts("typed-host", TargetSource::Argv);
196        let s = serde_json::to_string(&echo).expect("echo serializes");
197
198        assert!(s.contains(r#""target_resolved":"typed-host""#), "{s}");
199        assert!(s.contains(r#""target_source":"argv""#), "{s}");
200        assert!(s.contains(r#""host_resolved":"typed-host""#), "{s}");
201        assert!(s.contains(r#""host_source":"argv""#), "{s}");
202    }
203
204    /// A reader pinned to either spelling deserializes to the same target.
205    #[test]
206    fn either_spelling_round_trips() {
207        let echo = TargetEcho::from_parts("h", TargetSource::Selector);
208        let s = serde_json::to_string(&echo).expect("echo serializes");
209        let back: TargetEcho = serde_json::from_str(&s).expect("echo deserializes");
210
211        assert_eq!(back, echo);
212        assert_eq!(back.target_resolved, back.host_resolved);
213        assert_eq!(back.target_source, back.host_source);
214    }
215
216    /// The alias is one value under two names, never an independent field.
217    #[test]
218    fn the_alias_cannot_drift_from_the_canonical_name() {
219        let echo = TargetEcho::new(&ExecTarget::new("h", TargetSource::ActiveMarker));
220        assert_eq!(echo.target_resolved, echo.host_resolved);
221        assert_eq!(echo.target_source, echo.host_source);
222    }
223}