Skip to main content

release_kit/
probes.rs

1//! The environment probe catalog.
2//!
3//! One catalog, read by every caller that needs to know whether this host
4//! is ready: `rk doctor` runs it whole, and a mutating command guards the
5//! subset it depends on at entry, so the per-command guards and the
6//! doctor cannot drift apart. Each probe answers with a status, a
7//! message, and — on failure — the remediation printed verbatim wherever
8//! the probe is consulted.
9
10use std::process::Command;
11
12use serde::Serialize;
13
14/// How a failure weighs at the doctor level.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
16#[serde(rename_all = "kebab-case")]
17pub enum ProbeClass {
18    /// No mutating command can work without this.
19    Hard,
20    /// Needed only by some commands or some forges.
21    Soft,
22}
23
24/// What a probe found.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
26#[serde(rename_all = "kebab-case")]
27pub enum ProbeStatus {
28    /// The probe passed.
29    Ok,
30    /// The probe failed; the remediation says what fixes it.
31    Failed,
32}
33
34/// One probe's answer.
35#[derive(Debug, Serialize)]
36pub struct ProbeResult {
37    /// The probe's stable name.
38    pub id: &'static str,
39    /// How the failure weighs.
40    pub class: ProbeClass,
41    /// What was found.
42    pub status: ProbeStatus,
43    /// What was found, one line.
44    pub message: String,
45    /// The exact fix, when the probe failed.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub remediation: Option<String>,
48}
49
50impl ProbeResult {
51    fn ok(id: &'static str, class: ProbeClass, message: impl Into<String>) -> Self {
52        Self {
53            id,
54            class,
55            status: ProbeStatus::Ok,
56            message: message.into(),
57            remediation: None,
58        }
59    }
60
61    fn failed(
62        id: &'static str,
63        class: ProbeClass,
64        message: impl Into<String>,
65        remediation: impl Into<String>,
66    ) -> Self {
67        Self {
68            id,
69            class,
70            status: ProbeStatus::Failed,
71            message: message.into(),
72            remediation: Some(remediation.into()),
73        }
74    }
75}
76
77/// Run the whole catalog, in its stable order.
78#[must_use]
79pub fn run_all() -> Vec<ProbeResult> {
80    vec![
81        shell(),
82        state_root(),
83        git_remote(),
84        forge_cli(
85            "gh-auth",
86            "RK_GH_BIN",
87            "gh",
88            "the GitHub CLI",
89            "gh auth login",
90            // `gh auth status` fails when any stored account is broken,
91            // even while the active one works; `--active` judges only the
92            // credential this tool would use. Older gh lacks the flag, so
93            // the bare form is the fallback.
94            &[&["auth", "status", "--active"], &["auth", "status"]],
95        ),
96        forge_cli(
97            "glab-auth",
98            "RK_GLAB_BIN",
99            "glab",
100            "the GitLab CLI",
101            "glab auth login",
102            &[&["auth", "status"]],
103        ),
104        tool(
105            "openssl",
106            "RK_OPENSSL_BIN",
107            "openssl",
108            "OpenSSL; install-bot signs the App JWT with it",
109            &["version"],
110        ),
111        tool(
112            "curl",
113            "RK_CURL_BIN",
114            "curl",
115            "curl; install-bot reads the installation and rk versions --check fetches with it",
116            &["--version"],
117        ),
118    ]
119}
120
121/// A helper binary answers its version call. `env_override` names the
122/// substitute, which is also what keeps tests hermetic; presence is the
123/// whole question, because the tools here take no configuration.
124fn tool(
125    id: &'static str,
126    env_override: &str,
127    default_bin: &str,
128    label: &str,
129    args: &[&str],
130) -> ProbeResult {
131    let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
132    match Command::new(&bin).args(args).output() {
133        Ok(out) if out.status.success() => {
134            ProbeResult::ok(id, ProbeClass::Soft, format!("{default_bin} runs"))
135        }
136        Ok(_) => ProbeResult::failed(
137            id,
138            ProbeClass::Soft,
139            format!("{default_bin} does not answer {}", args.join(" ")),
140            format!("repair {label}"),
141        ),
142        Err(_) => ProbeResult::failed(
143            id,
144            ProbeClass::Soft,
145            format!("{default_bin} is not on PATH"),
146            format!("install {label}"),
147        ),
148    }
149}
150
151/// A POSIX shell runs; every setup step spawns through it.
152fn shell() -> ProbeResult {
153    let id = "sh";
154    match Command::new("sh").args(["-c", "exit 0"]).status() {
155        Ok(status) if status.success() => ProbeResult::ok(id, ProbeClass::Hard, "sh runs"),
156        Ok(status) => ProbeResult::failed(
157            id,
158            ProbeClass::Hard,
159            format!("sh exited {status}"),
160            "repair the POSIX shell on PATH",
161        ),
162        Err(source) => ProbeResult::failed(
163            id,
164            ProbeClass::Hard,
165            format!("sh does not spawn: {source}"),
166            "install a POSIX shell on PATH",
167        ),
168    }
169}
170
171/// The XDG state root accepts writes; the log and every run journal live
172/// under it.
173fn state_root() -> ProbeResult {
174    let id = "state-root";
175    let Some(root) = crate::applog::state_root() else {
176        return ProbeResult::failed(
177            id,
178            ProbeClass::Hard,
179            "neither XDG_STATE_HOME nor HOME is set",
180            "export HOME, or XDG_STATE_HOME",
181        );
182    };
183    let display = root.display().to_string();
184    let probe = root.join(format!(".probe-{}", std::process::id()));
185    let written = std::fs::create_dir_all(&root).and_then(|()| std::fs::write(&probe, b"probe"));
186    let _ = std::fs::remove_file(&probe);
187    match written {
188        Ok(()) => ProbeResult::ok(id, ProbeClass::Hard, format!("{display} is writable")),
189        Err(source) => ProbeResult::failed(
190            id,
191            ProbeClass::Hard,
192            format!("{display} is not writable: {source}"),
193            format!("make {display} writable"),
194        ),
195    }
196}
197
198/// The working directory's `origin` remote parses to a host, which is
199/// what forge and slug detection read.
200fn git_remote() -> ProbeResult {
201    let id = "git-remote";
202    let out = Command::new("git")
203        .args(["remote", "get-url", "origin"])
204        .output();
205    let url = match out {
206        Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
207        _ => {
208            return ProbeResult::failed(
209                id,
210                ProbeClass::Soft,
211                "the working directory has no origin remote",
212                "pass --repo <owner/name> where a command needs the slug",
213            );
214        }
215    };
216    // The raw remote never reaches the message: a malformed URL can carry
217    // userinfo — `https://user:token@…` — and a probe result lands in
218    // captured output and CI logs, where a credential must never appear.
219    remote_host(&url).map_or_else(
220        || {
221            ProbeResult::failed(
222                id,
223                ProbeClass::Soft,
224                "the origin remote does not parse to a host",
225                "pass --repo <owner/name> where a command needs the slug",
226            )
227        },
228        |host| ProbeResult::ok(id, ProbeClass::Soft, format!("origin resolves to {host}")),
229    )
230}
231
232/// The host in a git remote URL, for the `scp`-like and URL forms.
233fn remote_host(url: &str) -> Option<String> {
234    if let Some(rest) = url.split_once("://").map(|(_, rest)| rest) {
235        let authority = rest.split('/').next()?;
236        let host = authority
237            .rsplit_once('@')
238            .map_or(authority, |(_, host)| host);
239        let host = host.split(':').next()?;
240        return (!host.is_empty()).then(|| host.to_owned());
241    }
242    let (authority, path) = url.split_once(':')?;
243    let host = authority
244        .rsplit_once('@')
245        .map_or(authority, |(_, host)| host);
246    (!host.is_empty() && !path.is_empty()).then(|| host.to_owned())
247}
248
249/// A forge CLI is present and authenticated. `env_override` names the
250/// variable that substitutes the binary, which is also what keeps tests
251/// hermetic. `attempts` is tried in order and the first success wins, so
252/// a probe can prefer a sharper flag and still work where the CLI
253/// predates it.
254fn forge_cli(
255    id: &'static str,
256    env_override: &str,
257    default_bin: &str,
258    label: &str,
259    login: &str,
260    attempts: &[&[&str]],
261) -> ProbeResult {
262    let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
263    let mut spawned = false;
264    for args in attempts {
265        match Command::new(&bin).args(*args).output() {
266            Ok(out) if out.status.success() => {
267                return ProbeResult::ok(
268                    id,
269                    ProbeClass::Soft,
270                    format!("{default_bin} is authenticated"),
271                );
272            }
273            Ok(_) => spawned = true,
274            Err(_) => {}
275        }
276    }
277    if spawned {
278        ProbeResult::failed(
279            id,
280            ProbeClass::Soft,
281            format!("{default_bin} is not authenticated"),
282            format!("run {login}"),
283        )
284    } else {
285        ProbeResult::failed(
286            id,
287            ProbeClass::Soft,
288            format!("{default_bin} is not on PATH"),
289            format!("install {label}"),
290        )
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::remote_host;
297
298    #[test]
299    fn a_remote_host_parses_from_both_url_forms() {
300        assert_eq!(
301            remote_host("https://github.com/owner/name.git").as_deref(),
302            Some("github.com")
303        );
304        assert_eq!(
305            remote_host("git@gitlab.com:group/sub/name.git").as_deref(),
306            Some("gitlab.com")
307        );
308        assert_eq!(
309            remote_host("ssh://git@github.com:22/owner/name.git").as_deref(),
310            Some("github.com")
311        );
312        assert_eq!(remote_host("not a url"), None);
313    }
314}