#![forbid(unsafe_code)]
use super::health::{collect_health_check_batch, HostHealthResult};
use super::{load, winning_layer, HostSelection};
use crate::errors::SshCliError;
use crate::ssh::known_hosts::KnownHosts;
use anyhow::Result;
use std::path::Path;
pub(super) fn collect_doctor_local(config_override: Option<&Path>) -> Result<serde_json::Value> {
let layer = winning_layer(config_override)?;
let path = layer.path.clone();
let exists = path.exists();
let file = load(&path)?;
let kh = KnownHosts::path_beside_config(&path);
let active = path
.parent()
.map(|p| p.join(crate::constants::ACTIVE_VPS_FILE_NAME))
.unwrap_or_else(|| std::path::PathBuf::from(crate::constants::ACTIVE_VPS_FILE_NAME));
let perms = if exists {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
format!(
"{:o}",
std::fs::metadata(&path)?.permissions().mode() & 0o777
)
}
#[cfg(not(unix))]
{
"n/a".to_string()
}
} else {
"missing".to_string()
};
let seg = crate::secrets::secrets_status()?;
let runtime = crate::platform::detect_runtime();
if let Err(e) = crate::paths::validate_local_path_length(&path) {
tracing::warn!(error = %e, path = %path.display(), "config path length warning");
}
Ok(serde_json::json!({
"layer": layer.name,
"config_path": path.display().to_string(),
"exists": exists,
"permissions": perms,
"schema_version": file.schema_version,
"hosts": file.hosts.len(),
"known_hosts": kh.display().to_string(),
"active_file": active.display().to_string(),
"secrets_at_rest": if seg.encryption_active { "encrypted" } else { "plaintext" },
"secrets_key_source": seg.source.as_str(),
"secrets_key_file": seg.key_file_path.display().to_string(),
"secrets_plaintext_opt_out": seg.plaintext_opt_out,
"telemetry": false,
"runtime": {
"os": runtime.os,
"arch": runtime.arch,
"is_wsl": runtime.is_wsl,
"is_container": runtime.is_container,
"is_ci": runtime.is_ci,
"is_termux": runtime.is_termux,
"sandbox": runtime.sandbox,
},
}))
}
pub(super) async fn run_doctor_with_optional_probe(
config_override: Option<&Path>,
json: bool,
probe_ssh: bool,
probe_selection: Option<HostSelection>,
) -> Result<()> {
let local = collect_doctor_local(config_override)?;
let probe_data: Option<(Vec<HostHealthResult>, usize)> = if probe_ssh {
let selection = probe_selection.unwrap_or(HostSelection::All);
let (results, limit) =
collect_health_check_batch(&selection, config_override.map(Path::to_path_buf)).await?;
Some((results, limit))
} else {
None
};
if json {
let ssh_probe = match &probe_data {
None => serde_json::Value::Null,
Some((results, limit)) => {
let failures = results.iter().filter(|h| !h.ok).count();
serde_json::json!({
"event": "health-check-batch",
"max_concurrency": u32::try_from(*limit).unwrap_or(u32::MAX),
"hosts": results.len(),
"failures": failures,
"results": results.iter().map(|h| serde_json::json!({
"name": h.name,
"status": if h.ok { "ok" } else { "error" },
"latency_ms": h.latency_ms,
"error": h.error,
})).collect::<Vec<_>>(),
})
}
};
let probe_ok = probe_data
.as_ref()
.is_none_or(|(r, _)| r.iter().all(|h| h.ok));
let envelope = serde_json::json!({
"event": "vps-doctor",
"ok": probe_ok,
"local": local,
"ssh_probe": ssh_probe,
});
crate::output::print_json_value(&envelope)?;
if let Some((results, _)) = probe_data {
let failures = results.iter().filter(|h| !h.ok).count();
if failures > 0 {
return Err(SshCliError::Config(format!(
"{failures}/{} hosts failed health-check",
results.len()
))
.into());
}
}
return Ok(());
}
let layer = local
.get("layer")
.and_then(|v| v.as_str())
.unwrap_or("?");
let config_path_s = local
.get("config_path")
.and_then(|v| v.as_str())
.unwrap_or("");
let exists = local
.get("exists")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let perms = local
.get("permissions")
.and_then(|v| v.as_str())
.unwrap_or("");
let schema_version = local
.get("schema_version")
.and_then(serde_json::Value::as_u64)
.and_then(|v| u32::try_from(v).ok())
.unwrap_or(0);
let hosts = local
.get("hosts")
.and_then(serde_json::Value::as_u64)
.and_then(|v| usize::try_from(v).ok())
.unwrap_or(0);
let kh_s = local
.get("known_hosts")
.and_then(|v| v.as_str())
.unwrap_or("");
let active_s = local
.get("active_file")
.and_then(|v| v.as_str())
.unwrap_or("");
let secrets_at_rest = local
.get("secrets_at_rest")
.and_then(|v| v.as_str())
.unwrap_or("?");
let secrets_key_source = local
.get("secrets_key_source")
.and_then(|v| v.as_str())
.unwrap_or("?");
let key_file_s = local
.get("secrets_key_file")
.and_then(|v| v.as_str())
.unwrap_or("");
let plaintext_opt_out = local
.get("secrets_plaintext_opt_out")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
crate::output::print_doctor_text(
layer,
config_path_s,
exists,
perms,
schema_version,
hosts,
kh_s,
active_s,
secrets_at_rest,
secrets_key_source,
key_file_s,
plaintext_opt_out,
);
if let Some(rt) = local.get("runtime") {
crate::output::write_stderr_fmt(format_args!(
"runtime: os={} arch={} wsl={} container={} ci={} termux={} sandbox={}",
rt.get("os").and_then(|v| v.as_str()).unwrap_or("?"),
rt.get("arch").and_then(|v| v.as_str()).unwrap_or("?"),
rt.get("is_wsl")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
rt.get("is_container")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
rt.get("is_ci")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
rt.get("is_termux")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
rt.get("sandbox")
.and_then(|v| v.as_str())
.unwrap_or("none"),
))?;
}
if let Some((results, limit)) = probe_data {
crate::output::print_health_batch(&results, limit, false)?;
let failures = results.iter().filter(|h| !h.ok).count();
if failures > 0 {
return Err(SshCliError::Config(format!(
"{failures}/{} hosts failed health-check",
results.len()
))
.into());
}
}
Ok(())
}