//! Bootstrap host-side doctor artifacts on every archipelago startup. //! //! The update pipeline swaps the archipelago binary but does not touch //! scripts or systemd units — those are installed once by the ISO builder. //! Without this module, changes to `container-doctor.sh` or the doctor //! service/timer never reach boxes installed before the change. //! //! On startup we compare three embedded files against their on-disk //! copies and rewrite any that differ, then enable the doctor timer if //! it isn't already. Idempotent: no-ops on boxes that match the //! embedded version. All work is best-effort — failures are logged but //! never abort the backend. use anyhow::{Context, Result}; use std::path::Path; use tokio::fs; use tracing::{debug, info, warn}; use crate::update::host_sudo; const DOCTOR_SH: &str = include_str!("../../../scripts/container-doctor.sh"); const DOCTOR_SERVICE: &str = include_str!("../../../image-recipe/configs/archipelago-doctor.service"); const DOCTOR_TIMER: &str = include_str!("../../../image-recipe/configs/archipelago-doctor.timer"); const DOCTOR_SH_PATH: &str = "/home/archipelago/archy/scripts/container-doctor.sh"; const DOCTOR_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-doctor.service"; const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer"; /// Entry point called from main startup. Never returns an error to the caller — /// failing to bootstrap the doctor must not prevent the backend from serving. pub async fn ensure_doctor_installed() { match run().await { Ok(changed) if changed => info!("Doctor artifacts synchronized with binary"), Ok(_) => debug!("Doctor artifacts already in sync"), Err(e) => warn!("Doctor bootstrap failed (non-fatal): {:#}", e), } } async fn run() -> Result { // Dev-box guard: on contributors' laptops `/home/archipelago/archy` is // typically a symlink into the git checkout, and writing through it // would clobber the working tree with whatever the binary happens to // have been compiled from. Production ISO installs materialize a real // directory. let home_archy = Path::new("/home/archipelago/archy"); if fs::symlink_metadata(home_archy) .await .map(|m| m.file_type().is_symlink()) .unwrap_or(false) { debug!("/home/archipelago/archy is a symlink — skipping doctor bootstrap (dev box)"); return Ok(false); } // Skip entirely on machines without the canonical scripts directory — // writing orphan files there just causes confusion. let scripts_dir = Path::new(DOCTOR_SH_PATH) .parent() .context("doctor script path has no parent")?; if !scripts_dir.exists() { debug!( "Scripts dir {} missing — skipping doctor bootstrap", scripts_dir.display() ); return Ok(false); } let mut changed = false; // 1. Script — lives in archipelago's home dir, user-writable. if needs_write(DOCTOR_SH_PATH, DOCTOR_SH).await { fs::write(DOCTOR_SH_PATH, DOCTOR_SH) .await .with_context(|| format!("write {}", DOCTOR_SH_PATH))?; let _ = tokio::process::Command::new("chmod") .args(["+x", DOCTOR_SH_PATH]) .status() .await; info!("Updated {}", DOCTOR_SH_PATH); changed = true; } // 2. Systemd unit files — /etc is restricted; route through host_sudo. let service_changed = write_root_if_needed(DOCTOR_SERVICE_PATH, DOCTOR_SERVICE).await?; let timer_changed = write_root_if_needed(DOCTOR_TIMER_PATH, DOCTOR_TIMER).await?; changed = changed || service_changed || timer_changed; // 3. Reload + enable. Only when we actually touched units, or when the // timer isn't enabled yet (catches fresh upgrades of boxes that predate // the doctor entirely). let timer_enabled = is_timer_enabled().await; if service_changed || timer_changed || !timer_enabled { if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await { warn!("daemon-reload failed: {:#}", e); } if let Err(e) = host_sudo(&["systemctl", "enable", "--now", "archipelago-doctor.timer"]) .await { warn!("enable archipelago-doctor.timer failed: {:#}", e); } else if !timer_enabled { info!("Enabled archipelago-doctor.timer"); } } Ok(changed) } async fn needs_write(path: &str, expected: &str) -> bool { match fs::read_to_string(path).await { Ok(current) => current != expected, Err(_) => true, } } /// Write content to a root-owned path via `sudo mv` of a user-owned tmp file. /// Returns true if a write happened. async fn write_root_if_needed(path: &str, content: &str) -> Result { if !needs_write(path, content).await { return Ok(false); } let tmp = format!( "/tmp/archipelago-bootstrap-{}-{}.tmp", std::process::id(), Path::new(path) .file_name() .and_then(|n| n.to_str()) .unwrap_or("unit") ); fs::write(&tmp, content) .await .with_context(|| format!("write tmp {}", tmp))?; let status = host_sudo(&["mv", &tmp, path]) .await .with_context(|| format!("sudo mv {} -> {}", tmp, path))?; if !status.success() { let _ = fs::remove_file(&tmp).await; anyhow::bail!("sudo mv to {} exited with {}", path, status); } info!("Updated {}", path); Ok(true) } async fn is_timer_enabled() -> bool { tokio::process::Command::new("systemctl") .args(["is-enabled", "--quiet", "archipelago-doctor.timer"]) .status() .await .map(|s| s.success()) .unwrap_or(false) }