153 lines
5.7 KiB
Rust
153 lines
5.7 KiB
Rust
|
|
//! 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<bool> {
|
||
|
|
// 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<bool> {
|
||
|
|
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)
|
||
|
|
}
|