Skip to main content

release_tool/
doctor.rs

1use crate::command::{CommandRequest, CommandRunner};
2use crate::config::Config;
3use crate::config::TargetConfig;
4use crate::git::GitRepository;
5use anyhow::{Context, Result, bail};
6use serde::Deserialize;
7use std::path::Path;
8use std::sync::Arc;
9
10#[derive(Clone, Debug, Eq, PartialEq)]
11pub enum CapabilityStatus {
12    Verified,
13    Unverifiable,
14}
15
16#[derive(Clone, Debug, Eq, PartialEq)]
17pub struct Capability {
18    pub status: CapabilityStatus,
19    pub description: String,
20}
21
22#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct DoctorReport {
24    pub capabilities: Vec<Capability>,
25    pub actor: String,
26    pub token: Secret,
27}
28
29#[derive(Clone, Eq, PartialEq)]
30pub struct Secret(String);
31
32impl Secret {
33    pub fn new(value: impl Into<String>) -> Self {
34        Self(value.into())
35    }
36
37    pub fn expose(&self) -> &str {
38        &self.0
39    }
40}
41
42impl std::fmt::Debug for Secret {
43    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        formatter.write_str("Secret([REDACTED])")
45    }
46}
47
48#[derive(Deserialize)]
49#[serde(rename_all = "camelCase")]
50struct RepositoryView {
51    name_with_owner: String,
52    viewer_permission: String,
53}
54
55pub fn run_doctor(
56    config: &Config,
57    root: &Path,
58    runner: Arc<dyn CommandRunner>,
59) -> Result<DoctorReport> {
60    check_project_requirements(config, root, Arc::clone(&runner))?;
61    let staging = tempfile::tempdir().context("tool-owned staging directory is not writable")?;
62    std::fs::write(staging.path().join("probe"), b"release-tool doctor\n")
63        .context("tool-owned staging directory is not writable")?;
64    command(runner.as_ref(), root, "git", &["--version"])?;
65    command(runner.as_ref(), root, "gh", &["--version"])?;
66
67    let repository = GitRepository::new(root, Arc::clone(&runner));
68    repository.snapshot(&config.repository.branch)?;
69    let origin = repository.origin_url()?;
70    let identity = github_identity(&origin)
71        .with_context(|| format!("origin is not a GitHub repository URL: {origin}"))?;
72    if identity != config.repository.github {
73        bail!(
74            "configured repository `{}` does not match origin `{identity}`",
75            config.repository.github
76        );
77    }
78
79    command(
80        runner.as_ref(),
81        root,
82        "gh",
83        &["auth", "status", "--hostname", "github.com"],
84    )?;
85    let token = command(
86        runner.as_ref(),
87        root,
88        "gh",
89        &["auth", "token", "--hostname", "github.com"],
90    )?;
91    let token = token.trim().to_owned();
92    if token.is_empty() {
93        bail!("gh returned an empty GitHub token");
94    }
95    let actor = command(
96        runner.as_ref(),
97        root,
98        "gh",
99        &["api", "--hostname", "github.com", "user", "--jq", ".login"],
100    )?;
101    let actor = actor.trim().to_owned();
102    if actor.is_empty() {
103        bail!("gh returned an empty GitHub actor");
104    }
105    let view = command(
106        runner.as_ref(),
107        root,
108        "gh",
109        &[
110            "repo",
111            "view",
112            &config.repository.github,
113            "--json",
114            "nameWithOwner,viewerPermission",
115        ],
116    )?;
117    let view: RepositoryView =
118        serde_json::from_str(&view).context("invalid gh repo view output")?;
119    if view.name_with_owner != config.repository.github {
120        bail!(
121            "gh resolved `{}` instead of configured repository `{}`",
122            view.name_with_owner,
123            config.repository.github
124        );
125    }
126    if !matches!(
127        view.viewer_permission.as_str(),
128        "WRITE" | "MAINTAIN" | "ADMIN"
129    ) {
130        bail!(
131            "GitHub account only has {} permission for {}",
132            view.viewer_permission,
133            config.repository.github
134        );
135    }
136
137    let mut capabilities = vec![
138        Capability {
139            status: CapabilityStatus::Verified,
140            description: "Git repository".to_owned(),
141        },
142        Capability {
143            status: CapabilityStatus::Verified,
144            description: format!("GitHub account: {actor}"),
145        },
146        Capability {
147            status: CapabilityStatus::Verified,
148            description: format!("repository permission: {}", view.viewer_permission),
149        },
150        Capability {
151            status: CapabilityStatus::Verified,
152            description: "tool-owned staging directory".to_owned(),
153        },
154        Capability {
155            status: CapabilityStatus::Unverifiable,
156            description: "destination write permission".to_owned(),
157        },
158    ];
159    if config
160        .targets
161        .iter()
162        .any(|target| matches!(target, TargetConfig::OciImage { .. }))
163    {
164        capabilities.push(Capability {
165            status: CapabilityStatus::Verified,
166            description: "Docker buildx and OCI target commands".to_owned(),
167        });
168        capabilities.push(Capability {
169            status: CapabilityStatus::Unverifiable,
170            description: "OCI registry single-writer or immutable-tag enforcement".to_owned(),
171        });
172    }
173    Ok(DoctorReport {
174        capabilities,
175        actor,
176        token: Secret(token),
177    })
178}
179
180fn check_project_requirements(
181    config: &Config,
182    root: &Path,
183    runner: Arc<dyn CommandRunner>,
184) -> Result<()> {
185    if let Some(program) = config.hooks.preflight.first() {
186        require_command(root, program)?;
187    }
188    let mut needs_oci = false;
189    for target in &config.targets {
190        match target {
191            TargetConfig::DockerArchive {
192                build, local_check, ..
193            } => {
194                require_command(root, &build[0])?;
195                require_command(root, &local_check[0])?;
196                require_command(root, "docker")?;
197                require_command(root, "xz")?;
198            }
199            TargetConfig::MavenReactor {
200                wrapper,
201                pom,
202                projects,
203                remote_check,
204                ..
205            } => {
206                require_command(root, &wrapper.display().to_string())?;
207                let root_pom = root.join(pom);
208                if !root_pom.is_file() {
209                    bail!("Maven root POM does not exist: {}", root_pom.display());
210                }
211                for project in projects {
212                    let project_pom = root.join(project).join("pom.xml");
213                    if !project_pom.is_file() {
214                        bail!(
215                            "Maven project POM does not exist: {}",
216                            project_pom.display()
217                        );
218                    }
219                }
220                if let Some(program) = remote_check.first() {
221                    require_command(root, program)?;
222                }
223                let publisher = config
224                    .publishers
225                    .get(target.publisher())
226                    .context("Maven target publisher disappeared during doctor")?;
227                if let crate::config::PublisherConfig::GithubMaven { settings, .. } = publisher {
228                    let settings = root.join(settings);
229                    if !settings.is_file() {
230                        bail!("Maven settings file does not exist: {}", settings.display());
231                    }
232                }
233            }
234            TargetConfig::OciImage {
235                reuse_check, build, ..
236            } => {
237                require_command(root, &reuse_check[0])?;
238                require_command(root, &build[0])?;
239                needs_oci = true;
240            }
241        }
242    }
243    if needs_oci {
244        require_command(root, "docker")?;
245        command(runner.as_ref(), root, "docker", &["buildx", "version"])?;
246        let inspect_help = command(
247            runner.as_ref(),
248            root,
249            "docker",
250            &["buildx", "imagetools", "inspect", "--help"],
251        )?;
252        if !inspect_help.contains("--format") || !inspect_help.contains("--raw") {
253            bail!("docker buildx imagetools inspect does not support --format and --raw");
254        }
255        let create_help = command(
256            runner.as_ref(),
257            root,
258            "docker",
259            &["buildx", "imagetools", "create", "--help"],
260        )?;
261        if !create_help.contains("--prefer-index") {
262            bail!("docker buildx imagetools create does not support --prefer-index");
263        }
264    }
265    Ok(())
266}
267
268fn require_command(root: &Path, program: &str) -> Result<()> {
269    let program_path = Path::new(program);
270    if program_path.components().count() > 1 || program_path.is_absolute() {
271        let path = if program_path.is_absolute() {
272            program_path.to_path_buf()
273        } else {
274            root.join(program_path)
275        };
276        if path.is_file() {
277            return Ok(());
278        }
279    } else if std::env::var_os("PATH")
280        .map(|path| {
281            std::env::split_paths(&path)
282                .map(|directory| directory.join(program))
283                .any(|candidate| candidate.is_file())
284        })
285        .unwrap_or(false)
286    {
287        return Ok(());
288    }
289    bail!("required command `{program}` was not found")
290}
291
292fn command(
293    runner: &dyn CommandRunner,
294    root: &Path,
295    program: &str,
296    arguments: &[&str],
297) -> Result<String> {
298    let request = CommandRequest::new(program, arguments.iter().copied(), root);
299    Ok(runner
300        .execute(&request)?
301        .require_success(&format!("{} {}", program, arguments.join(" ")))?
302        .stdout)
303}
304
305fn github_identity(url: &str) -> Option<String> {
306    let url = url.trim().trim_end_matches(".git");
307    if let Some(identity) = url.strip_prefix("https://github.com/") {
308        return Some(identity.to_owned());
309    }
310    if let Some(identity) = url.strip_prefix("git@github.com:") {
311        return Some(identity.to_owned());
312    }
313    if let Some(identity) = url.strip_prefix("ssh://git@github.com/") {
314        return Some(identity.to_owned());
315    }
316    None
317}