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        tool(
119            "cosign",
120            "RK_COSIGN_BIN",
121            "cosign",
122            "cosign; the release verify step checks a GitLab provenance bundle with it",
123            &["version"],
124        ),
125        tool(
126            "pypi-attestations",
127            "RK_PYPI_ATTESTATIONS_BIN",
128            "pypi-attestations",
129            "pypi-attestations; the release verify step checks a PyPI distribution's attestations with it",
130            &["--help"],
131        ),
132    ]
133}
134
135/// A helper binary answers its version call. `env_override` names the
136/// substitute, which is also what keeps tests hermetic; presence is the
137/// whole question, because the tools here take no configuration.
138fn tool(
139    id: &'static str,
140    env_override: &str,
141    default_bin: &str,
142    label: &str,
143    args: &[&str],
144) -> ProbeResult {
145    let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
146    match Command::new(&bin).args(args).output() {
147        Ok(out) if out.status.success() => {
148            ProbeResult::ok(id, ProbeClass::Soft, format!("{default_bin} runs"))
149        }
150        Ok(_) => ProbeResult::failed(
151            id,
152            ProbeClass::Soft,
153            format!("{default_bin} does not answer {}", args.join(" ")),
154            format!("repair {label}"),
155        ),
156        Err(_) => ProbeResult::failed(
157            id,
158            ProbeClass::Soft,
159            format!("{default_bin} is not on PATH"),
160            format!("install {label}"),
161        ),
162    }
163}
164
165/// A POSIX shell runs; every setup step spawns through it.
166fn shell() -> ProbeResult {
167    let id = "sh";
168    match Command::new("sh").args(["-c", "exit 0"]).status() {
169        Ok(status) if status.success() => ProbeResult::ok(id, ProbeClass::Hard, "sh runs"),
170        Ok(status) => ProbeResult::failed(
171            id,
172            ProbeClass::Hard,
173            format!("sh exited {status}"),
174            "repair the POSIX shell on PATH",
175        ),
176        Err(source) => ProbeResult::failed(
177            id,
178            ProbeClass::Hard,
179            format!("sh does not spawn: {source}"),
180            "install a POSIX shell on PATH",
181        ),
182    }
183}
184
185/// The XDG state root accepts writes; the log and every run journal live
186/// under it.
187fn state_root() -> ProbeResult {
188    let id = "state-root";
189    let Some(root) = crate::applog::state_root() else {
190        return ProbeResult::failed(
191            id,
192            ProbeClass::Hard,
193            "neither XDG_STATE_HOME nor HOME is set",
194            "export HOME, or XDG_STATE_HOME",
195        );
196    };
197    let display = root.display().to_string();
198    let probe = root.join(format!(".probe-{}", std::process::id()));
199    let written = std::fs::create_dir_all(&root).and_then(|()| std::fs::write(&probe, b"probe"));
200    let _ = std::fs::remove_file(&probe);
201    match written {
202        Ok(()) => ProbeResult::ok(id, ProbeClass::Hard, format!("{display} is writable")),
203        Err(source) => ProbeResult::failed(
204            id,
205            ProbeClass::Hard,
206            format!("{display} is not writable: {source}"),
207            format!("make {display} writable"),
208        ),
209    }
210}
211
212/// The working directory's `origin` remote parses to a host, which is
213/// what forge and slug detection read.
214fn git_remote() -> ProbeResult {
215    let id = "git-remote";
216    let out = Command::new("git")
217        .args(["remote", "get-url", "origin"])
218        .output();
219    let url = match out {
220        Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
221        _ => {
222            return ProbeResult::failed(
223                id,
224                ProbeClass::Soft,
225                "the working directory has no origin remote",
226                "pass --repo <owner/name> where a command needs the slug",
227            );
228        }
229    };
230    // The raw remote never reaches the message: a malformed URL can carry
231    // userinfo — `https://user:token@…` — and a probe result lands in
232    // captured output and CI logs, where a credential must never appear.
233    remote_host(&url).map_or_else(
234        || {
235            ProbeResult::failed(
236                id,
237                ProbeClass::Soft,
238                "the origin remote does not parse to a host",
239                "pass --repo <owner/name> where a command needs the slug",
240            )
241        },
242        |host| ProbeResult::ok(id, ProbeClass::Soft, format!("origin resolves to {host}")),
243    )
244}
245
246/// The host in a git remote URL, for the `scp`-like and URL forms.
247fn remote_host(url: &str) -> Option<String> {
248    if let Some(rest) = url.split_once("://").map(|(_, rest)| rest) {
249        let authority = rest.split('/').next()?;
250        let host = authority
251            .rsplit_once('@')
252            .map_or(authority, |(_, host)| host);
253        let host = host.split(':').next()?;
254        return (!host.is_empty()).then(|| host.to_owned());
255    }
256    let (authority, path) = url.split_once(':')?;
257    let host = authority
258        .rsplit_once('@')
259        .map_or(authority, |(_, host)| host);
260    (!host.is_empty() && !path.is_empty()).then(|| host.to_owned())
261}
262
263/// A forge CLI is present and authenticated. `env_override` names the
264/// variable that substitutes the binary, which is also what keeps tests
265/// hermetic. `attempts` is tried in order and the first success wins, so
266/// a probe can prefer a sharper flag and still work where the CLI
267/// predates it.
268fn forge_cli(
269    id: &'static str,
270    env_override: &str,
271    default_bin: &str,
272    label: &str,
273    login: &str,
274    attempts: &[&[&str]],
275) -> ProbeResult {
276    let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
277    let mut spawned = false;
278    for args in attempts {
279        match Command::new(&bin).args(*args).output() {
280            Ok(out) if out.status.success() => {
281                return ProbeResult::ok(
282                    id,
283                    ProbeClass::Soft,
284                    format!("{default_bin} is authenticated"),
285                );
286            }
287            Ok(_) => spawned = true,
288            Err(_) => {}
289        }
290    }
291    if spawned {
292        ProbeResult::failed(
293            id,
294            ProbeClass::Soft,
295            format!("{default_bin} is not authenticated"),
296            format!("run {login}"),
297        )
298    } else {
299        ProbeResult::failed(
300            id,
301            ProbeClass::Soft,
302            format!("{default_bin} is not on PATH"),
303            format!("install {label}"),
304        )
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::remote_host;
311
312    #[test]
313    fn a_remote_host_parses_from_both_url_forms() {
314        assert_eq!(
315            remote_host("https://github.com/owner/name.git").as_deref(),
316            Some("github.com")
317        );
318        assert_eq!(
319            remote_host("git@gitlab.com:group/sub/name.git").as_deref(),
320            Some("gitlab.com")
321        );
322        assert_eq!(
323            remote_host("ssh://git@github.com:22/owner/name.git").as_deref(),
324            Some("github.com")
325        );
326        assert_eq!(remote_host("not a url"), None);
327    }
328}