use std::fmt;
use std::fs;
use std::path::PathBuf;
use crate::config::schema::Config;
use crate::system::tailscale;
const MIN_SUBID_RANGE: u32 = 65536;
const MIN_PODMAN: (u32, u32) = (5, 3);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Blocker,
Warning,
Info,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Issue {
PodmanUnsupported {
found: Option<String>,
},
SubidNotConfigured {
user: String,
missing_files: Vec<&'static str>,
},
SubidRangeTooSmall {
user: String,
current: u32,
minimum: u32,
},
TailscaleCliMissing,
TailscaleNotLoggedIn,
AuthSsoDesync { service: String },
TailscaleServiceUnapproved { service: String, svc_name: String },
DanglingSymlink { link: PathBuf, target: PathBuf },
OrphanQuadletFile { path: PathBuf },
MissingMetadata { service: String },
NativeSourceMissing { service: String, source: PathBuf },
BrokenEnvFileRef {
service: String,
quadlet: PathBuf,
env_file: PathBuf,
},
LingerNotEnabled,
IntegrityScanFailed { error: String },
}
impl Issue {
pub fn severity(&self) -> Severity {
match self {
Issue::PodmanUnsupported { .. } => Severity::Blocker,
Issue::SubidNotConfigured { .. } | Issue::SubidRangeTooSmall { .. } => {
Severity::Blocker
}
Issue::TailscaleCliMissing | Issue::TailscaleNotLoggedIn => Severity::Warning,
Issue::AuthSsoDesync { .. } => Severity::Warning,
Issue::TailscaleServiceUnapproved { .. } => Severity::Warning,
Issue::DanglingSymlink { .. } | Issue::OrphanQuadletFile { .. } => Severity::Warning,
Issue::BrokenEnvFileRef { .. } => Severity::Warning,
Issue::LingerNotEnabled => Severity::Warning,
Issue::MissingMetadata { .. } => Severity::Info,
Issue::NativeSourceMissing { .. } => Severity::Warning,
Issue::IntegrityScanFailed { .. } => Severity::Warning,
}
}
}
impl fmt::Display for Issue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Issue::PodmanUnsupported { found } => match found {
Some(version) => write!(
f,
"podman {version} is too old — ryra needs podman >= {}.{} \
(quadlet env expansion in PublishPort/Volume).\n\
\n\
Fix: upgrade podman — current Debian-based, Fedora, and Arch \
releases all ship a supported version.",
MIN_PODMAN.0, MIN_PODMAN.1,
),
None => write!(
f,
"podman isn't on PATH — ryra runs every service as a rootless \
podman container.\n\
\n\
Fix:\n \
sudo apt install podman # Debian-based\n \
sudo dnf install podman # Fedora\n \
sudo pacman -S podman # Arch",
),
},
Issue::SubidNotConfigured {
user,
missing_files,
} => {
write!(
f,
"rootless podman needs subuid/subgid mappings, but {} has no entry in {}.\n\
\n\
Fix:\n \
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 {}\n \
podman system migrate",
user,
missing_files.join(" / "),
user,
)
}
Issue::SubidRangeTooSmall {
user,
current,
minimum,
} => {
write!(
f,
"rootless podman needs at least {minimum} subuids/subgids, but {user} has only {current}.\n\
Containers with non-zero UIDs (postgres, nginx, etc.) will fail to extract.\n\
\n\
Fix:\n \
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 {user}\n \
podman system migrate",
)
}
Issue::TailscaleCliMissing => {
write!(
f,
"the `tailscale` CLI isn't on PATH.\n\
\n\
Fix (Debian/Ubuntu):\n \
curl -fsSL https://tailscale.com/install.sh | sh\n\
Or drop --tailscale and reach the service via Caddy \
(run `ryra add caddy` first) or your own URL (--url).",
)
}
Issue::TailscaleNotLoggedIn => {
write!(
f,
"this node isn't logged into a tailnet.\n\
`tailscale status` doesn't return a *.ts.net hostname.\n\
\n\
Fix:\n \
sudo tailscale up",
)
}
Issue::AuthSsoDesync { service } => {
write!(
f,
"{service} is configured for OIDC SSO, but the auth provider has no client \
registered for it, so SSO is broken even though ryra's metadata says it's \
wired. Often follows a `ryra backup restore` of the provider from a \
snapshot taken before {service} was added with --auth.\n\
\n\
Fix (re-registers using the existing client credentials in {service}'s \
.env, no secret rotation):\n \
ryra configure {service} --reassert-auth -y",
)
}
Issue::TailscaleServiceUnapproved { service, svc_name } => {
write!(
f,
"{service} is exposed on your tailnet (svc:{svc_name}) but the control \
plane hasn't approved this host to serve it, so its *.ts.net URL routes \
nowhere even though the container is healthy.\n\
\n\
Fix (most common: tailscaled didn't push the advertisement):\n \
sudo systemctl restart tailscaled\n\
If it stays unapproved, your tailnet ACL isn't auto-approving the service. \
Confirm with:\n \
sudo tailscale status --json | jq '.Self.CapMap[\"service-host\"]'\n\
and add the service to autoApprovers.services in the ACL (or approve the \
host in the admin console).",
)
}
Issue::DanglingSymlink { link, target } => {
write!(
f,
"{} is a dangling symlink → {} (target missing).\n\
The service's data dir was moved, renamed, or deleted, but the \
systemd unit pointer wasn't updated.\n\
\n\
Fix (restore the dir if it was moved, or drop the unit):\n \
# put the data dir back so {} exists again\n \
# or: rm {}",
link.display(),
target.display(),
target.display(),
link.display(),
)
}
Issue::OrphanQuadletFile { path } => {
write!(
f,
"{} exists but no matching symlink in ~/.config/containers/systemd/, so systemd doesn't see it.\n\
\n\
Fix (re-link):\n \
ln -sf {} ~/.config/containers/systemd/{}\n \
systemctl --user daemon-reload\n\
Or delete the orphan: ryra remove --purge <service>",
path.display(),
path.display(),
path.file_name().and_then(|n| n.to_str()).unwrap_or("?"),
)
}
Issue::MissingMetadata { service } => {
write!(
f,
"{service} is installed but has no metadata.toml — install record from a pre-metadata ryra version.\n\
`ryra list` and `ryra remove` will work but URL/exposure won't be reported.\n\
\n\
Fix (reinstall to migrate):\n \
ryra remove --purge {service} && ryra add {service}",
)
}
Issue::NativeSourceMissing { service, source } => {
write!(
f,
"{service} (native) runs from {} but that directory is gone \
(deleted or moved). It can't start or rebuild.\n\
\n\
Fix (restore the source, then re-render):\n \
# put the project back at {}, then: ryra upgrade {service}\n \
# or drop the install: ryra remove --purge {service}",
source.display(),
source.display(),
)
}
Issue::BrokenEnvFileRef {
service,
quadlet,
env_file,
} => {
write!(
f,
"{} references EnvironmentFile={} but that file doesn't exist.\n\
The unit can't start — and ${{SERVICE_HOME}}/${{SERVICE_PORT_*}} in it \
would expand to empty strings.\n\
Usually the service's data dir was moved or renamed, or the .env was deleted.\n\
\n\
Fix (restore the path, or reinstall):\n \
# put the data back at {}, then: systemctl --user restart {service}\n \
# or: ryra remove --purge {service} && ryra add {service}",
quadlet.display(),
env_file.display(),
env_file
.parent()
.unwrap_or_else(|| std::path::Path::new("?"))
.display(),
)
}
Issue::LingerNotEnabled => {
write!(
f,
"loginctl linger isn't enabled, so your user services stop when you log out.\n\
\n\
Fix:\n \
loginctl enable-linger",
)
}
Issue::IntegrityScanFailed { error } => {
write!(
f,
"couldn't scan installed services to check for drift: {error}\n\
Fix the underlying error (commonly a permissions problem on \
~/.config/containers/systemd/ or ~/.local/share/services/) so \
`ryra doctor` can verify install state.",
)
}
}
}
}
pub fn check_all(_config: &Config) -> Vec<Issue> {
let mut issues = Vec::new();
if let Err(e) = check_podman_version() {
issues.push(e);
}
if let Err(e) = check_subid_range() {
issues.push(e);
}
if !check_linger_enabled() {
issues.push(Issue::LingerNotEnabled);
}
issues.extend(check_install_integrity());
issues
}
pub fn blockers(config: &Config) -> Vec<Issue> {
check_all(config)
.into_iter()
.filter(|i| i.severity() == Severity::Blocker)
.collect()
}
pub fn check_tailscale_runtime() -> Result<(), Issue> {
if !tailscale::cli_available() {
return Err(Issue::TailscaleCliMissing);
}
if tailscale::self_dns_name().is_none() {
return Err(Issue::TailscaleNotLoggedIn);
}
Ok(())
}
pub fn check_auth_wiring() -> Vec<Issue> {
if !crate::is_service_installed(crate::WellKnownService::Authelia.as_str()) {
return Vec::new();
}
let Ok(installed) = crate::list_installed() else {
return Vec::new();
};
let mut issues = Vec::new();
for svc in &installed {
if svc.auth_kind.is_none() {
continue;
}
if crate::authelia::oidc_client_registered(&svc.name) == Some(false) {
issues.push(Issue::AuthSsoDesync {
service: svc.name.clone(),
});
}
}
issues
}
pub fn check_tailscale_services() -> Vec<Issue> {
let Ok(installed) = crate::list_installed() else {
return Vec::new();
};
let mut issues = Vec::new();
for svc in &installed {
if !svc.exposure.is_tailscale() {
continue;
}
let Some(svc_name) = svc.exposure.tailscale_svc_name() else {
continue;
};
if tailscale::is_service_approved(&svc_name) == Some(false) {
issues.push(Issue::TailscaleServiceUnapproved {
service: svc.name.clone(),
svc_name,
});
}
}
issues
}
fn check_podman_version() -> Result<(), Issue> {
let Ok(output) = std::process::Command::new("podman")
.arg("--version")
.output()
else {
return Err(Issue::PodmanUnsupported { found: None });
};
let text = String::from_utf8_lossy(&output.stdout);
let Some((major, minor, patch)) = parse_podman_version(&text) else {
return Err(Issue::PodmanUnsupported {
found: Some(text.trim().to_string()),
});
};
if (major, minor) < MIN_PODMAN {
return Err(Issue::PodmanUnsupported {
found: Some(format!("{major}.{minor}.{patch}")),
});
}
Ok(())
}
fn parse_podman_version(s: &str) -> Option<(u32, u32, u32)> {
let nums = s.split_whitespace().last()?;
let mut parts = nums.split('.');
let digits = |p: &str| -> Option<u32> {
let d: String = p.chars().take_while(|c| c.is_ascii_digit()).collect();
d.parse().ok()
};
let major = digits(parts.next()?)?;
let minor = digits(parts.next()?)?;
let patch = parts.next().and_then(digits).unwrap_or(0);
Some((major, minor, patch))
}
fn check_subid_range() -> Result<(), Issue> {
let user = std::env::var("USER").unwrap_or_default();
if user.is_empty() {
return Ok(());
}
let mut missing = Vec::new();
let subuid_size = parse_subid_range("/etc/subuid", &user, &mut missing);
let subgid_size = parse_subid_range("/etc/subgid", &user, &mut missing);
if !missing.is_empty() {
return Err(Issue::SubidNotConfigured {
user,
missing_files: missing,
});
}
let min = subuid_size.min(subgid_size);
if min < MIN_SUBID_RANGE {
return Err(Issue::SubidRangeTooSmall {
user,
current: min,
minimum: MIN_SUBID_RANGE,
});
}
Ok(())
}
fn check_linger_enabled() -> bool {
let user = match std::env::var("USER") {
Ok(u) if !u.is_empty() => u,
_ => return true,
};
let output = std::process::Command::new("loginctl")
.args(["show-user", &user, "--property=Linger"])
.output();
match output {
Ok(o) if o.status.success() => {
let stdout = String::from_utf8_lossy(&o.stdout);
!stdout.trim().eq_ignore_ascii_case("Linger=no")
}
_ => true,
}
}
fn broken_env_file_refs(service: &str, quadlet_path: &std::path::Path) -> Vec<Issue> {
let Ok(content) = std::fs::read_to_string(quadlet_path) else {
return Vec::new();
};
let Ok(home) = crate::home_dir() else {
return Vec::new();
};
let mut out = Vec::new();
for line in content.lines() {
let Some(value) = line.trim().strip_prefix("EnvironmentFile=") else {
continue;
};
let value = value.trim();
if value.is_empty() || value.starts_with('-') {
continue;
}
let resolved = PathBuf::from(value.replace("%h", &home.to_string_lossy()));
if !resolved.exists()
&& !out.iter().any(
|i| matches!(i, Issue::BrokenEnvFileRef { env_file, .. } if *env_file == resolved),
)
{
out.push(Issue::BrokenEnvFileRef {
service: service.to_string(),
quadlet: quadlet_path.to_path_buf(),
env_file: resolved,
});
}
}
out
}
fn check_install_integrity() -> Vec<Issue> {
let mut out = Vec::new();
let Ok(quadlet) = crate::quadlet_dir() else {
return out;
};
let Ok(data_root) = crate::service_data_root() else {
return out;
};
if let Ok(entries) = std::fs::read_dir(&quadlet) {
for entry in entries.flatten() {
let path = entry.path();
let Ok(meta) = std::fs::symlink_metadata(&path) else {
continue;
};
if !meta.file_type().is_symlink() {
continue;
}
let Ok(target) = std::fs::read_link(&path) else {
continue;
};
let resolved = if target.is_absolute() {
target.clone()
} else {
let Some(parent) = path.parent() else {
continue;
};
parent.join(&target)
};
if !resolved.starts_with(&data_root) {
continue;
}
if !resolved.exists() {
out.push(Issue::DanglingSymlink {
link: path,
target: resolved,
});
}
}
}
let managed = match crate::scan_managed_services() {
Ok(m) => m,
Err(e) => {
out.push(Issue::IntegrityScanFailed {
error: e.to_string(),
});
return out;
}
};
for svc in &managed {
let Ok(home) = crate::service_home(svc) else {
continue;
};
if !home.is_dir() {
continue;
}
if let Ok(meta_path) = crate::metadata_path(svc)
&& !meta_path.exists()
{
out.push(Issue::MissingMetadata {
service: svc.clone(),
});
}
if let Ok(entries) = std::fs::read_dir(&home) {
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let n = name.to_string_lossy();
if !(n.ends_with(".container") || n.ends_with(".network") || n.ends_with(".volume"))
{
continue;
}
let symlink = quadlet.join(&name);
let symlink_ok = std::fs::read_link(&symlink)
.ok()
.and_then(|t| {
if t.is_absolute() {
Some(t)
} else {
symlink.parent().map(|p| p.join(&t))
}
})
.is_some_and(|resolved| resolved == path);
if !symlink_ok {
out.push(Issue::OrphanQuadletFile { path: path.clone() });
}
if n.ends_with(".container") {
out.extend(broken_env_file_refs(svc, &path));
}
}
}
}
if let Ok(root) = crate::paths::service_data_root()
&& let Ok(entries) = std::fs::read_dir(&root)
{
for entry in entries.flatten() {
let Some(svc) = entry.file_name().to_str().map(str::to_string) else {
continue;
};
let Ok(Some(meta)) = crate::metadata::load_metadata(&svc) else {
continue;
};
if meta.runtime != crate::registry::service_def::Runtime::Native {
continue;
}
if crate::registry::resolve::is_path_like(&meta.registry) {
let source = PathBuf::from(&meta.registry);
if !source.is_dir() {
out.push(Issue::NativeSourceMissing {
service: svc,
source,
});
}
}
}
}
out
}
fn parse_subid_range(path: &'static str, user: &str, missing: &mut Vec<&'static str>) -> u32 {
let contents = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => {
missing.push(path);
return 0;
}
};
for line in contents.lines() {
let mut parts = line.splitn(3, ':');
let Some(name) = parts.next() else { continue };
if name != user {
continue;
}
let _start = parts.next();
let count = parts
.next()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(0);
return count;
}
missing.push(path);
0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn podman_version_parsing() {
assert_eq!(
parse_podman_version("podman version 5.8.2"),
Some((5, 8, 2))
);
assert_eq!(
parse_podman_version("podman version 4.9.3"),
Some((4, 9, 3))
);
assert_eq!(
parse_podman_version("podman version 5.9.0-dev"),
Some((5, 9, 0))
);
assert_eq!(parse_podman_version("podman version 6.0"), Some((6, 0, 0)));
assert_eq!(parse_podman_version("garbage"), None);
assert!((5, 3) >= MIN_PODMAN);
assert!((5, 2) < MIN_PODMAN);
assert!((4, 9) < MIN_PODMAN);
}
#[test]
fn display_too_small_includes_fix_command() {
let e = Issue::SubidRangeTooSmall {
user: "alice".into(),
current: 1000,
minimum: 65536,
};
let s = format!("{e}");
assert!(s.contains("usermod --add-subuids"));
assert!(s.contains("alice"));
assert!(s.contains("podman system migrate"));
}
#[test]
fn display_not_configured_lists_files() {
let e = Issue::SubidNotConfigured {
user: "bob".into(),
missing_files: vec!["/etc/subuid", "/etc/subgid"],
};
let s = format!("{e}");
assert!(s.contains("/etc/subuid"));
assert!(s.contains("/etc/subgid"));
}
#[test]
fn tailscale_cli_missing_display_has_install_hint() {
let s = format!("{}", Issue::TailscaleCliMissing);
assert!(s.contains("tailscale.com/install"));
assert!(s.contains("ryra add caddy") && s.contains("--url"));
}
#[test]
fn tailscale_not_logged_in_display_has_up_command() {
let s = format!("{}", Issue::TailscaleNotLoggedIn);
assert!(s.contains("tailscale up"));
}
#[test]
fn auth_sso_desync_display_names_service_and_nonrotating_fix() {
let issue = Issue::AuthSsoDesync {
service: "seafile".into(),
};
assert_eq!(issue.severity(), Severity::Warning);
let s = format!("{issue}");
assert!(s.contains("seafile"));
assert!(s.contains("ryra configure seafile --reassert-auth"));
}
#[test]
fn tailscale_unapproved_display_names_service_and_fix() {
let issue = Issue::TailscaleServiceUnapproved {
service: "vikunja".into(),
svc_name: "vikunja-debian".into(),
};
assert_eq!(issue.severity(), Severity::Warning);
let s = format!("{issue}");
assert!(s.contains("vikunja") && s.contains("svc:vikunja-debian"));
assert!(s.contains("systemctl restart tailscaled"));
assert!(s.contains("autoApprovers.services"));
}
#[test]
fn severity_split() {
assert_eq!(
Issue::SubidRangeTooSmall {
user: "x".into(),
current: 0,
minimum: 1,
}
.severity(),
Severity::Blocker
);
assert_eq!(
Issue::DanglingSymlink {
link: "/a".into(),
target: "/b".into(),
}
.severity(),
Severity::Warning
);
assert_eq!(
Issue::MissingMetadata {
service: "x".into(),
}
.severity(),
Severity::Info
);
}
#[test]
fn dangling_symlink_display_has_rm_fix() {
let s = format!(
"{}",
Issue::DanglingSymlink {
link: "/x/foo.container".into(),
target: "/y/foo.container".into(),
}
);
assert!(s.contains("rm /x/foo.container"));
}
#[test]
fn missing_metadata_display_suggests_reinstall() {
let s = format!(
"{}",
Issue::MissingMetadata {
service: "forgejo".into(),
}
);
assert!(s.contains("ryra remove --purge forgejo"));
assert!(s.contains("ryra add forgejo"));
}
}