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 camino::{Utf8Path, Utf8PathBuf};
13use serde::Serialize;
14
15use crate::skills::record::{RECORD_PATH, Record};
16use crate::skills::{AGENTS_ROOT, CLAUDE_ROOT, Digest, SHARED_ROOT};
17
18/// How a failure weighs at the doctor level.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "kebab-case")]
21pub enum ProbeClass {
22    /// No mutating command can work without this.
23    Hard,
24    /// Needed only by some commands or some forges.
25    Soft,
26}
27
28/// What a probe found.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "kebab-case")]
31pub enum ProbeStatus {
32    /// The probe passed.
33    Ok,
34    /// The probe failed; the remediation says what fixes it.
35    Failed,
36}
37
38/// One probe's answer.
39#[derive(Debug, Serialize)]
40pub struct ProbeResult {
41    /// The probe's stable name.
42    pub id: &'static str,
43    /// How the failure weighs.
44    pub class: ProbeClass,
45    /// What was found.
46    pub status: ProbeStatus,
47    /// What was found, one line.
48    pub message: String,
49    /// The exact fix, when the probe failed.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub remediation: Option<String>,
52}
53
54impl ProbeResult {
55    fn ok(id: &'static str, class: ProbeClass, message: impl Into<String>) -> Self {
56        Self {
57            id,
58            class,
59            status: ProbeStatus::Ok,
60            message: message.into(),
61            remediation: None,
62        }
63    }
64
65    fn failed(
66        id: &'static str,
67        class: ProbeClass,
68        message: impl Into<String>,
69        remediation: impl Into<String>,
70    ) -> Self {
71        Self {
72            id,
73            class,
74            status: ProbeStatus::Failed,
75            message: message.into(),
76            remediation: Some(remediation.into()),
77        }
78    }
79}
80
81/// The probes judging the skill installation itself, in catalog order.
82///
83/// Declared here rather than derived by running the catalog: the shared plan
84/// gate's pre-flight phase must name each of these, and the test holding it to
85/// that must not have to spawn a forge CLI or write into the operator's home
86/// to learn what they are.
87pub const SKILL_PROBES: [&str; 3] = ["skill-roots", "skill-gate", "skill-payload"];
88
89/// Run the whole catalog, in its stable order.
90#[must_use]
91pub fn run_all() -> Vec<ProbeResult> {
92    vec![
93        shell(),
94        state_root(),
95        skill_roots(),
96        skill_gate(),
97        skill_payload(),
98        git_remote(),
99        forge_cli(
100            "gh-auth",
101            "RK_GH_BIN",
102            "gh",
103            "the GitHub CLI",
104            "gh auth login",
105            // `gh auth status` fails when any stored account is broken,
106            // even while the active one works; `--active` judges only the
107            // credential this tool would use. Older gh lacks the flag, so
108            // the bare form is the fallback.
109            &[&["auth", "status", "--active"], &["auth", "status"]],
110        ),
111        forge_cli(
112            "glab-auth",
113            "RK_GLAB_BIN",
114            "glab",
115            "the GitLab CLI",
116            "glab auth login",
117            &[&["auth", "status"]],
118        ),
119        tool(
120            "openssl",
121            "RK_OPENSSL_BIN",
122            "openssl",
123            "OpenSSL; install-bot signs the App JWT with it",
124            &["version"],
125        ),
126        tool(
127            "curl",
128            "RK_CURL_BIN",
129            "curl",
130            "curl; install-bot reads the installation and rk versions --check fetches with it",
131            &["--version"],
132        ),
133        tool(
134            "cosign",
135            "RK_COSIGN_BIN",
136            "cosign",
137            "cosign; the release verify step checks a GitLab provenance bundle with it",
138            &["version"],
139        ),
140        tool(
141            "pypi-attestations",
142            "RK_PYPI_ATTESTATIONS_BIN",
143            "pypi-attestations",
144            "pypi-attestations; the release verify step checks a PyPI distribution's attestations with it",
145            &["--help"],
146        ),
147    ]
148}
149
150/// A helper binary answers its version call. `env_override` names the
151/// substitute, which is also what keeps tests hermetic; presence is the
152/// whole question, because the tools here take no configuration.
153fn tool(
154    id: &'static str,
155    env_override: &str,
156    default_bin: &str,
157    label: &str,
158    args: &[&str],
159) -> ProbeResult {
160    let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
161    match Command::new(&bin).args(args).output() {
162        Ok(out) if out.status.success() => {
163            ProbeResult::ok(id, ProbeClass::Soft, format!("{default_bin} runs"))
164        }
165        Ok(_) => ProbeResult::failed(
166            id,
167            ProbeClass::Soft,
168            format!("{default_bin} does not answer {}", args.join(" ")),
169            format!("repair {label}"),
170        ),
171        Err(_) => ProbeResult::failed(
172            id,
173            ProbeClass::Soft,
174            format!("{default_bin} is not on PATH"),
175            format!("install {label}"),
176        ),
177    }
178}
179
180/// A POSIX shell runs; every setup step spawns through it.
181fn shell() -> ProbeResult {
182    let id = "sh";
183    match Command::new("sh").args(["-c", "exit 0"]).status() {
184        Ok(status) if status.success() => ProbeResult::ok(id, ProbeClass::Hard, "sh runs"),
185        Ok(status) => ProbeResult::failed(
186            id,
187            ProbeClass::Hard,
188            format!("sh exited {status}"),
189            "repair the POSIX shell on PATH",
190        ),
191        Err(source) => ProbeResult::failed(
192            id,
193            ProbeClass::Hard,
194            format!("sh does not spawn: {source}"),
195            "install a POSIX shell on PATH",
196        ),
197    }
198}
199
200/// The XDG state root accepts writes; the log and every run journal live
201/// under it.
202fn state_root() -> ProbeResult {
203    let id = "state-root";
204    let Some(root) = crate::applog::state_root() else {
205        return ProbeResult::failed(
206            id,
207            ProbeClass::Hard,
208            "neither XDG_STATE_HOME nor HOME is set",
209            "export HOME, or XDG_STATE_HOME",
210        );
211    };
212    let display = root.display().to_string();
213    let probe = root.join(format!(".probe-{}", std::process::id()));
214    let written = std::fs::create_dir_all(&root).and_then(|()| std::fs::write(&probe, b"probe"));
215    let _ = std::fs::remove_file(&probe);
216    match written {
217        Ok(()) => ProbeResult::ok(id, ProbeClass::Hard, format!("{display} is writable")),
218        Err(source) => ProbeResult::failed(
219            id,
220            ProbeClass::Hard,
221            format!("{display} is not writable: {source}"),
222            format!("make {display} writable"),
223        ),
224    }
225}
226
227/// The destinations `rk skill install` writes accept writes: the two agent
228/// roots and the shared root, all under the invoking user's home.
229///
230/// A root can exist and still refuse, which is what a read-only bind of an
231/// agent directory produces, so what is tested is the nearest existing
232/// ancestor — the directory an install would actually have to write
233/// through. The probe creates nothing: a preview must still be able to
234/// report a root as absent, and a probe that made it exist would take that
235/// answer away.
236fn skill_roots() -> ProbeResult {
237    let id = SKILL_PROBES[0];
238    let Ok(home) = crate::skills::home() else {
239        return ProbeResult::failed(
240            id,
241            ProbeClass::Soft,
242            "neither HOME nor USERPROFILE is set, so no skill root resolves",
243            "export HOME",
244        );
245    };
246    let mut refused = Vec::new();
247    for root in [CLAUDE_ROOT, AGENTS_ROOT, SHARED_ROOT] {
248        let root = home.join(root);
249        let Some(existing) = nearest_existing(&root) else {
250            refused.push(format!("no ancestor of {root} exists"));
251            continue;
252        };
253        if let Err(source) = accepts_a_write(&existing) {
254            refused.push(format!("{existing} is not writable: {source}"));
255        }
256    }
257    if refused.is_empty() {
258        ProbeResult::ok(
259            id,
260            ProbeClass::Soft,
261            format!("the skill roots under {home} accept writes"),
262        )
263    } else {
264        ProbeResult::failed(
265            id,
266            ProbeClass::Soft,
267            refused.join("; "),
268            format!("make the skill roots under {home} writable"),
269        )
270    }
271}
272
273/// The artifacts every skill shares are installed, and are this binary's.
274///
275/// This is the probe that answers the one failure a shared home produces.
276/// The agent roots and the shared root are separate directories, so a
277/// container, a sandbox, or a sync that carries one and not the other
278/// leaves every skill resolvable by name and unable to read the gates it is
279/// told to read first. A skill that cannot read them runs neither its
280/// pre-flight nor its plan phase, which is the whole reason they are files
281/// rather than prose.
282fn skill_gate() -> ProbeResult {
283    let id = SKILL_PROBES[1];
284    let Ok(home) = crate::skills::home() else {
285        return ProbeResult::failed(
286            id,
287            ProbeClass::Soft,
288            "neither HOME nor USERPROFILE is set, so the shared root does not resolve",
289            "export HOME",
290        );
291    };
292    let root = home.join(SHARED_ROOT);
293    let record = Record::load(&home.join(RECORD_PATH));
294    let planned: Vec<(Utf8PathBuf, &'static [u8])> = crate::skills::shared()
295        .into_iter()
296        .map(|artifact| (root.join(&artifact.path), artifact.bytes))
297        .collect();
298    let found = judge(planned, &record);
299    if let Some(first) = found.missing.first() {
300        return ProbeResult::failed(
301            id,
302            ProbeClass::Soft,
303            format!("a shared artifact every skill reads before acting is not installed: {first}"),
304            "rk skill install --apply",
305        );
306    }
307    if !found.differing.is_empty() {
308        return ProbeResult::failed(
309            id,
310            ProbeClass::Soft,
311            format!(
312                "{} shared artifact(s) under {root} are not this binary's",
313                found.differing.len()
314            ),
315            reinstall(found.all_recorded),
316        );
317    }
318    ProbeResult::ok(
319        id,
320        ProbeClass::Soft,
321        format!("{root} holds this binary's shared artifacts"),
322    )
323}
324
325/// The skills installed under this home are the ones this binary carries.
326///
327/// One binary serves every repository, so a skill under an agent root and
328/// the `rk` on PATH are two artifacts that can be updated apart: a home
329/// shared with a container, a sandbox, or another machine can hold skills
330/// some other build installed. The probe names that drift rather than
331/// leaving an agent to follow instructions the binary no longer answers.
332fn skill_payload() -> ProbeResult {
333    let id = SKILL_PROBES[2];
334    let Ok(home) = crate::skills::home() else {
335        return ProbeResult::failed(
336            id,
337            ProbeClass::Soft,
338            "neither HOME nor USERPROFILE is set, so no agent root resolves",
339            "export HOME",
340        );
341    };
342    let Ok(skills) = crate::skills::all() else {
343        return ProbeResult::failed(
344            id,
345            ProbeClass::Soft,
346            "this binary's embedded skills do not read",
347            "reinstall rk; the payload it was built from is defective",
348        );
349    };
350    let record = Record::load(&home.join(RECORD_PATH));
351    let mut planned = Vec::new();
352    for root in [CLAUDE_ROOT, AGENTS_ROOT] {
353        let root = home.join(root);
354        // An absent agent root is a choice, not a defect: `--agent` selects
355        // one family and leaves the other's root untouched.
356        if !root.is_dir() {
357            continue;
358        }
359        for skill in &skills {
360            planned.push((
361                root.join(&skill.name).join("SKILL.md"),
362                skill.text.as_bytes(),
363            ));
364        }
365    }
366    if planned.is_empty() {
367        return ProbeResult::failed(
368            id,
369            ProbeClass::Soft,
370            format!("no agent skill root exists under {home}"),
371            "rk skill install --apply",
372        );
373    }
374    let found = judge(planned, &record);
375    if let Some(first) = found.missing.first() {
376        return ProbeResult::failed(
377            id,
378            ProbeClass::Soft,
379            format!(
380                "{} of this binary's skills are not installed, the first at {first}",
381                found.missing.len()
382            ),
383            "rk skill install --apply",
384        );
385    }
386    if !found.differing.is_empty() {
387        return ProbeResult::failed(
388            id,
389            ProbeClass::Soft,
390            format!(
391                "{} installed skill(s) are not this binary's; rk is {}",
392                found.differing.len(),
393                env!("CARGO_PKG_VERSION")
394            ),
395            reinstall(found.all_recorded),
396        );
397    }
398    ProbeResult::ok(
399        id,
400        ProbeClass::Soft,
401        format!(
402            "{} installed skill destination(s) are this binary's",
403            found.matching
404        ),
405    )
406}
407
408/// What sits at each destination the payload names.
409struct Installed {
410    /// Destinations the payload names that hold no readable file.
411    missing: Vec<Utf8PathBuf>,
412    /// Destinations holding bytes that are not this binary's.
413    differing: Vec<Utf8PathBuf>,
414    /// How many destinations hold exactly this binary's bytes.
415    matching: usize,
416    /// Whether the record vouches for every differing destination, which
417    /// makes the difference a stale install rather than the operator's own
418    /// edit — and decides whether the fix needs `--force`.
419    all_recorded: bool,
420}
421
422/// Judge each destination the payload names against what sits on disk.
423fn judge(planned: Vec<(Utf8PathBuf, &'static [u8])>, record: &Record) -> Installed {
424    let mut found = Installed {
425        missing: Vec::new(),
426        differing: Vec::new(),
427        matching: 0,
428        all_recorded: true,
429    };
430    for (destination, bytes) in planned {
431        match std::fs::read(&destination) {
432            Ok(held) if held == bytes => found.matching += 1,
433            Ok(held) => {
434                if !record.wrote(&destination, &Digest::of(&held)) {
435                    found.all_recorded = false;
436                }
437                found.differing.push(destination);
438            }
439            Err(_) => found.missing.push(destination),
440        }
441    }
442    found
443}
444
445/// The install that corrects a difference. Bytes the record vouches for are
446/// an older release's and go without asking; bytes it cannot account for are
447/// the operator's own, and overwriting those is what `--force` is.
448const fn reinstall(all_recorded: bool) -> &'static str {
449    if all_recorded {
450        "rk skill install --apply"
451    } else {
452        "rk skill install --apply --force"
453    }
454}
455
456/// The nearest ancestor of `path`, itself included, that exists as a
457/// directory.
458fn nearest_existing(path: &Utf8Path) -> Option<Utf8PathBuf> {
459    let mut current = Some(path);
460    while let Some(dir) = current {
461        if dir.is_dir() {
462            return Some(dir.to_owned());
463        }
464        current = dir.parent();
465    }
466    None
467}
468
469/// A directory accepts a write, leaving nothing behind.
470fn accepts_a_write(dir: &Utf8Path) -> std::io::Result<()> {
471    let probe = dir.join(format!(".rk-probe-{}", std::process::id()));
472    let written = std::fs::write(&probe, b"probe");
473    let _ = std::fs::remove_file(&probe);
474    written
475}
476
477/// The working directory's `origin` remote parses to a host, which is
478/// what forge and slug detection read.
479fn git_remote() -> ProbeResult {
480    let id = "git-remote";
481    let out = Command::new("git")
482        .args(["remote", "get-url", "origin"])
483        .output();
484    let url = match out {
485        Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
486        _ => {
487            return ProbeResult::failed(
488                id,
489                ProbeClass::Soft,
490                "the working directory has no origin remote",
491                "pass --repo <owner/name> where a command needs the slug",
492            );
493        }
494    };
495    // The raw remote never reaches the message: a malformed URL can carry
496    // userinfo — `https://user:token@…` — and a probe result lands in
497    // captured output and CI logs, where a credential must never appear.
498    remote_host(&url).map_or_else(
499        || {
500            ProbeResult::failed(
501                id,
502                ProbeClass::Soft,
503                "the origin remote does not parse to a host",
504                "pass --repo <owner/name> where a command needs the slug",
505            )
506        },
507        |host| ProbeResult::ok(id, ProbeClass::Soft, format!("origin resolves to {host}")),
508    )
509}
510
511/// The host in a git remote URL, for the `scp`-like and URL forms.
512fn remote_host(url: &str) -> Option<String> {
513    if let Some(rest) = url.split_once("://").map(|(_, rest)| rest) {
514        let authority = rest.split('/').next()?;
515        let host = authority
516            .rsplit_once('@')
517            .map_or(authority, |(_, host)| host);
518        let host = host.split(':').next()?;
519        return (!host.is_empty()).then(|| host.to_owned());
520    }
521    let (authority, path) = url.split_once(':')?;
522    let host = authority
523        .rsplit_once('@')
524        .map_or(authority, |(_, host)| host);
525    (!host.is_empty() && !path.is_empty()).then(|| host.to_owned())
526}
527
528/// A forge CLI is present and authenticated. `env_override` names the
529/// variable that substitutes the binary, which is also what keeps tests
530/// hermetic. `attempts` is tried in order and the first success wins, so
531/// a probe can prefer a sharper flag and still work where the CLI
532/// predates it.
533fn forge_cli(
534    id: &'static str,
535    env_override: &str,
536    default_bin: &str,
537    label: &str,
538    login: &str,
539    attempts: &[&[&str]],
540) -> ProbeResult {
541    let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
542    let mut spawned = false;
543    for args in attempts {
544        match Command::new(&bin).args(*args).output() {
545            Ok(out) if out.status.success() => {
546                return ProbeResult::ok(
547                    id,
548                    ProbeClass::Soft,
549                    format!("{default_bin} is authenticated"),
550                );
551            }
552            Ok(_) => spawned = true,
553            Err(_) => {}
554        }
555    }
556    if spawned {
557        ProbeResult::failed(
558            id,
559            ProbeClass::Soft,
560            format!("{default_bin} is not authenticated"),
561            format!("run {login}"),
562        )
563    } else {
564        ProbeResult::failed(
565            id,
566            ProbeClass::Soft,
567            format!("{default_bin} is not on PATH"),
568            format!("install {label}"),
569        )
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::remote_host;
576
577    #[test]
578    fn a_remote_host_parses_from_both_url_forms() {
579        assert_eq!(
580            remote_host("https://github.com/owner/name.git").as_deref(),
581            Some("github.com")
582        );
583        assert_eq!(
584            remote_host("git@gitlab.com:group/sub/name.git").as_deref(),
585            Some("gitlab.com")
586        );
587        assert_eq!(
588            remote_host("ssh://git@github.com:22/owner/name.git").as_deref(),
589            Some("github.com")
590        );
591        assert_eq!(remote_host("not a url"), None);
592    }
593}