use crate::command::{CommandRequest, CommandRunner};
use crate::config::Config;
use crate::config::TargetConfig;
use crate::git::GitRepository;
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use std::path::Path;
use std::sync::Arc;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CapabilityStatus {
Verified,
Unverifiable,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Capability {
pub status: CapabilityStatus,
pub description: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DoctorReport {
pub capabilities: Vec<Capability>,
pub actor: String,
pub token: Secret,
}
#[derive(Clone, Eq, PartialEq)]
pub struct Secret(String);
impl Secret {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn expose(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for Secret {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("Secret([REDACTED])")
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct RepositoryView {
name_with_owner: String,
viewer_permission: String,
}
pub fn run_doctor(
config: &Config,
root: &Path,
runner: Arc<dyn CommandRunner>,
) -> Result<DoctorReport> {
check_project_requirements(config, root, Arc::clone(&runner))?;
let staging = tempfile::tempdir().context("tool-owned staging directory is not writable")?;
std::fs::write(staging.path().join("probe"), b"release-tool doctor\n")
.context("tool-owned staging directory is not writable")?;
command(runner.as_ref(), root, "git", &["--version"])?;
command(runner.as_ref(), root, "gh", &["--version"])?;
let repository = GitRepository::new(root, Arc::clone(&runner));
repository.snapshot(&config.repository.branch)?;
let origin = repository.origin_url()?;
let identity = github_identity(&origin)
.with_context(|| format!("origin is not a GitHub repository URL: {origin}"))?;
if identity != config.repository.github {
bail!(
"configured repository `{}` does not match origin `{identity}`",
config.repository.github
);
}
command(
runner.as_ref(),
root,
"gh",
&["auth", "status", "--hostname", "github.com"],
)?;
let token = command(
runner.as_ref(),
root,
"gh",
&["auth", "token", "--hostname", "github.com"],
)?;
let token = token.trim().to_owned();
if token.is_empty() {
bail!("gh returned an empty GitHub token");
}
let actor = command(
runner.as_ref(),
root,
"gh",
&["api", "--hostname", "github.com", "user", "--jq", ".login"],
)?;
let actor = actor.trim().to_owned();
if actor.is_empty() {
bail!("gh returned an empty GitHub actor");
}
let view = command(
runner.as_ref(),
root,
"gh",
&[
"repo",
"view",
&config.repository.github,
"--json",
"nameWithOwner,viewerPermission",
],
)?;
let view: RepositoryView =
serde_json::from_str(&view).context("invalid gh repo view output")?;
if view.name_with_owner != config.repository.github {
bail!(
"gh resolved `{}` instead of configured repository `{}`",
view.name_with_owner,
config.repository.github
);
}
if !matches!(
view.viewer_permission.as_str(),
"WRITE" | "MAINTAIN" | "ADMIN"
) {
bail!(
"GitHub account only has {} permission for {}",
view.viewer_permission,
config.repository.github
);
}
let mut capabilities = vec![
Capability {
status: CapabilityStatus::Verified,
description: "Git repository".to_owned(),
},
Capability {
status: CapabilityStatus::Verified,
description: format!("GitHub account: {actor}"),
},
Capability {
status: CapabilityStatus::Verified,
description: format!("repository permission: {}", view.viewer_permission),
},
Capability {
status: CapabilityStatus::Verified,
description: "tool-owned staging directory".to_owned(),
},
Capability {
status: CapabilityStatus::Unverifiable,
description: "destination write permission".to_owned(),
},
];
if config
.targets
.iter()
.any(|target| matches!(target, TargetConfig::OciImage { .. }))
{
capabilities.push(Capability {
status: CapabilityStatus::Verified,
description: "Docker buildx and OCI target commands".to_owned(),
});
capabilities.push(Capability {
status: CapabilityStatus::Unverifiable,
description: "OCI registry single-writer or immutable-tag enforcement".to_owned(),
});
}
Ok(DoctorReport {
capabilities,
actor,
token: Secret(token),
})
}
fn check_project_requirements(
config: &Config,
root: &Path,
runner: Arc<dyn CommandRunner>,
) -> Result<()> {
if let Some(program) = config.hooks.preflight.first() {
require_command(root, program)?;
}
let mut needs_oci = false;
for target in &config.targets {
match target {
TargetConfig::DockerArchive {
build, local_check, ..
} => {
require_command(root, &build[0])?;
require_command(root, &local_check[0])?;
require_command(root, "docker")?;
require_command(root, "xz")?;
}
TargetConfig::MavenReactor {
wrapper,
pom,
projects,
remote_check,
..
} => {
require_command(root, &wrapper.display().to_string())?;
let root_pom = root.join(pom);
if !root_pom.is_file() {
bail!("Maven root POM does not exist: {}", root_pom.display());
}
for project in projects {
let project_pom = root.join(project).join("pom.xml");
if !project_pom.is_file() {
bail!(
"Maven project POM does not exist: {}",
project_pom.display()
);
}
}
if let Some(program) = remote_check.first() {
require_command(root, program)?;
}
let publisher = config
.publishers
.get(target.publisher())
.context("Maven target publisher disappeared during doctor")?;
if let crate::config::PublisherConfig::GithubMaven { settings, .. } = publisher {
let settings = root.join(settings);
if !settings.is_file() {
bail!("Maven settings file does not exist: {}", settings.display());
}
}
}
TargetConfig::OciImage {
reuse_check, build, ..
} => {
require_command(root, &reuse_check[0])?;
require_command(root, &build[0])?;
needs_oci = true;
}
}
}
if needs_oci {
require_command(root, "docker")?;
command(runner.as_ref(), root, "docker", &["buildx", "version"])?;
let inspect_help = command(
runner.as_ref(),
root,
"docker",
&["buildx", "imagetools", "inspect", "--help"],
)?;
if !inspect_help.contains("--format") || !inspect_help.contains("--raw") {
bail!("docker buildx imagetools inspect does not support --format and --raw");
}
let create_help = command(
runner.as_ref(),
root,
"docker",
&["buildx", "imagetools", "create", "--help"],
)?;
if !create_help.contains("--prefer-index") {
bail!("docker buildx imagetools create does not support --prefer-index");
}
}
Ok(())
}
fn require_command(root: &Path, program: &str) -> Result<()> {
let program_path = Path::new(program);
if program_path.components().count() > 1 || program_path.is_absolute() {
let path = if program_path.is_absolute() {
program_path.to_path_buf()
} else {
root.join(program_path)
};
if path.is_file() {
return Ok(());
}
} else if std::env::var_os("PATH")
.map(|path| {
std::env::split_paths(&path)
.map(|directory| directory.join(program))
.any(|candidate| candidate.is_file())
})
.unwrap_or(false)
{
return Ok(());
}
bail!("required command `{program}` was not found")
}
fn command(
runner: &dyn CommandRunner,
root: &Path,
program: &str,
arguments: &[&str],
) -> Result<String> {
let request = CommandRequest::new(program, arguments.iter().copied(), root);
Ok(runner
.execute(&request)?
.require_success(&format!("{} {}", program, arguments.join(" ")))?
.stdout)
}
fn github_identity(url: &str) -> Option<String> {
let url = url.trim().trim_end_matches(".git");
if let Some(identity) = url.strip_prefix("https://github.com/") {
return Some(identity.to_owned());
}
if let Some(identity) = url.strip_prefix("git@github.com:") {
return Some(identity.to_owned());
}
if let Some(identity) = url.strip_prefix("ssh://git@github.com/") {
return Some(identity.to_owned());
}
None
}