diff --git a/api/openapi.json b/api/openapi.json index 825668d1..2cd138f7 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -10,7 +10,7 @@ "name": "MIT OR Apache-2.0", "identifier": "MIT OR Apache-2.0" }, - "version": "0.22.2" + "version": "0.22.3" }, "paths": { "/api/v1/clients": { diff --git a/crates/punktfunk-host/src/update.rs b/crates/punktfunk-host/src/update.rs index 886a05ff..895dfed1 100644 --- a/crates/punktfunk-host/src/update.rs +++ b/crates/punktfunk-host/src/update.rs @@ -101,6 +101,9 @@ pub(crate) fn apply_support() -> &'static str { { "full" } + // The Deck source rebuild is user-owned — no helper, no group. + #[cfg(target_os = "linux")] + detect::InstallKind::SteamosSource => "full", _ => "notify", } } @@ -421,12 +424,15 @@ pub(crate) fn start_apply(force: bool, session_active: bool) -> Result<(), Apply | detect::InstallKind::Sysext | detect::InstallKind::RpmOstree | detect::InstallKind::Pacman + | detect::InstallKind::SteamosSource ); if !windows_leg && !linux_leg { return Err(ApplyError::Unsupported); } #[cfg(target_os = "linux")] - if linux_leg { + if linux_leg && kind != detect::InstallKind::SteamosSource { + // The Deck source rebuild is user-owned and needs no root helper; every other Linux + // leg goes through it. if !linux::helper_installed() { return Err(ApplyError::Unsupported); } @@ -519,7 +525,12 @@ pub(crate) fn start_apply(force: bool, session_active: bool) -> Result<(), Apply #[cfg(target_os = "linux")] { let _ = &asset; // the Linux legs resolve through the package manager - linux::run_apply(&target_version, serial, &stage).map(|()| { + let run = if detect::detect().0 == detect::InstallKind::SteamosSource { + linux::run_apply_steamos(&target_version, serial, &stage) + } else { + linux::run_apply(&target_version, serial, &stage) + }; + run.map(|()| { // Staged / nothing-to-do wrote a durable result and this process lives // on; an in-place change wrote the intent and our restart is queued. // Either way the in-process job is finished. diff --git a/crates/punktfunk-host/src/update/jobs.rs b/crates/punktfunk-host/src/update/jobs.rs index 0ff32eb2..d19dd428 100644 --- a/crates/punktfunk-host/src/update/jobs.rs +++ b/crates/punktfunk-host/src/update/jobs.rs @@ -35,6 +35,13 @@ pub(crate) struct IntentRecord { pub installer_sha256: String, /// Where the installer was told to log (`/LOG=`). pub log_path: String, + /// A source rebuild (Steam Deck `update.sh`), where version equality proves nothing — + /// the workspace version only moves on bumps. The flow's own ordering carries the + /// proof instead: the script restarts the host ONLY after a successful build+install, + /// and its failure path is reported live (the host survives a failed build). So an + /// intent with this flag still present at boot ⟹ the rebuild succeeded. + #[serde(default)] + pub source_build: bool, } /// The durable outcome of the most recent apply attempt. @@ -124,6 +131,20 @@ pub(crate) fn reconcile( let Some(intent) = intent else { return Reconciled::None; }; + if intent.source_build { + // See `IntentRecord::source_build`: presence at boot IS the success signal; the + // running version is the truthful "to" (a rebuild can legitimately keep it). + return Reconciled::Success(ResultRecord { + ok: true, + from: intent.from, + to: current_version.to_string(), + finished_unix: now_unix, + stage: None, + error: None, + log_path: Some(intent.log_path), + staged: false, + }); + } if current_version == intent.to { return Reconciled::Success(ResultRecord { ok: true, @@ -167,6 +188,22 @@ mod tests { started_unix: started, installer_sha256: "ab".repeat(32), log_path: "/logs/update-0.23.200.log".into(), + source_build: false, + } + } + + #[test] + fn source_build_intent_is_success_with_the_running_version() { + let mut i = intent(0); + i.source_build = true; + // Same version as `from` (a rebuild without a bump) — still success, and `to` is + // what actually runs, not the manifest's label. + match reconcile(Some(i), "0.23.100", 10_000_000) { + Reconciled::Success(r) => { + assert!(r.ok); + assert_eq!(r.to, "0.23.100"); + } + other => panic!("expected success, got {other:?}"), } } diff --git a/crates/punktfunk-host/src/update/linux.rs b/crates/punktfunk-host/src/update/linux.rs index 2b3d0806..5327abfd 100644 --- a/crates/punktfunk-host/src/update/linux.rs +++ b/crates/punktfunk-host/src/update/linux.rs @@ -99,6 +99,110 @@ pub(super) fn opt_in_hint() -> String { .to_string() } +/// The Steam Deck source-rebuild leg (plan U3.1): run `~/punktfunk/scripts/steamdeck/update.sh +/// --pull` in a TRANSIENT user unit — `systemd-run`, because the script ends by restarting +/// `punktfunk-host.service`, and a child inside our own cgroup would be killed by that restart +/// mid-run. No root involved (the Deck install is user-owned). Outcome plumbing: +/// - build FAILS → the script exits without restarting us → the poll below sees the unit fail +/// and reports it live, log attached; +/// - build SUCCEEDS → the script restarts us → we die mid-poll; the `source_build` intent at +/// next boot IS the success signal (`jobs::reconcile`). +pub(super) fn run_apply_steamos( + target_version: &str, + serial: u64, + stage: &dyn Fn(&'static str), +) -> Result<(), (&'static str, String)> { + let home = std::env::var("HOME").map_err(|_| ("applying", "no $HOME".to_string()))?; + let script = std::path::Path::new(&home).join("punktfunk/scripts/steamdeck/update.sh"); + if !script.exists() { + return Err(( + "applying", + format!( + "{} not found — is this the Deck on-device install?", + script.display() + ), + )); + } + let log = pf_paths::config_dir() + .join("logs") + .join("update-steamos.log"); + if let Some(dir) = log.parent() { + let _ = std::fs::create_dir_all(dir); + } + + jobs::write_json_atomic( + &jobs::intent_path(), + &IntentRecord { + from: env!("PUNKTFUNK_VERSION").into(), + to: target_version.into(), + serial, + started_unix: super::now_unix(), + installer_sha256: String::new(), + log_path: log.display().to_string(), + source_build: true, + }, + ) + .map_err(|e| ("applying", format!("write intent record: {e}")))?; + + const UNIT: &str = "pf-source-update"; + // `--collect` reaps the transient unit even on failure, so a retry can reuse the name. + let launched = Command::new("systemd-run") + .args(["--user", "--collect", "--unit", UNIT, "bash", "-c"]) + .arg(format!( + "exec >> '{}' 2>&1; exec bash '{}' --pull", + log.display(), + script.display() + )) + .status(); + match launched { + Ok(s) if s.success() => {} + Ok(s) => { + let _ = std::fs::remove_file(jobs::intent_path()); + return Err(("applying", format!("systemd-run exited {s}"))); + } + Err(e) => { + let _ = std::fs::remove_file(jobs::intent_path()); + return Err(("applying", format!("launch systemd-run: {e}"))); + } + } + stage("applying"); + + // Follow the transient unit. A successful build restarts this process before the unit + // goes inactive, so leaving this loop alive means either "still building" or "failed". + let deadline = Instant::now() + Duration::from_secs(90 * 60); + loop { + std::thread::sleep(Duration::from_secs(5)); + let state = capture(Command::new("systemctl").args(["--user", "is-active", UNIT])) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| "failed".into()); + match state.as_str() { + "active" | "activating" | "deactivating" | "reloading" => { + if Instant::now() > deadline { + // Leave the build running (killing a half-linked build helps nobody) but + // stop claiming it; the intent stays for reconcile if it ever finishes. + return Err(( + "applying", + format!( + "the source rebuild is still running after 90 min — following it \ + ends here; see {}", + log.display() + ), + )); + } + } + // The unit ended and we are STILL ALIVE ⇒ the script never reached its restart + // step ⇒ the build failed (an up-to-date tree still rebuilds+restarts). + _ => { + let _ = std::fs::remove_file(jobs::intent_path()); + return Err(( + "applying", + format!("the source rebuild failed — see {}", log.display()), + )); + } + } + } +} + /// Run the whole Linux apply: start the oneshot, wait, interpret its result record, and for /// an in-place binary change, write the intent and restart ourselves (boot reconciliation /// reports the outcome). Blocking — run on a blocking thread. @@ -244,6 +348,7 @@ pub(super) fn run_apply( started_unix: super::now_unix(), installer_sha256: String::new(), log_path: "journalctl -u punktfunk-update.service".into(), + source_build: false, }, ) .map_err(|e| ("restarting", format!("write intent record: {e}")))?; diff --git a/crates/punktfunk-host/src/update/windows.rs b/crates/punktfunk-host/src/update/windows.rs index 4e6776bc..3bddd53e 100644 --- a/crates/punktfunk-host/src/update/windows.rs +++ b/crates/punktfunk-host/src/update/windows.rs @@ -95,6 +95,7 @@ pub(super) fn run_apply( started_unix: super::now_unix(), installer_sha256: asset.sha256.to_ascii_lowercase(), log_path: log.display().to_string(), + source_build: false, }, ) .map_err(|e| ("applying", format!("write intent record: {e}")))?; diff --git a/docs-site/content/docs/updating.md b/docs-site/content/docs/updating.md index 0bb68e49..e0bf4cd3 100644 --- a/docs-site/content/docs/updating.md +++ b/docs-site/content/docs/updating.md @@ -72,6 +72,11 @@ requires `PACMAN_FULL_SYSUPGRADE=1` in `/etc/punktfunk/update.conf`, because the pacman update is a full `pacman -Syu` — partial upgrades are how Arch boxes break, and we won't run one. After a successful update the host restarts itself and the page reconnects. +The **Steam Deck on-device build** gets the button too, with no opt-in (it's your own user's +install, no root involved): it runs the same `update.sh` rebuild the docs describe, which +compiles on the Deck — expect it to take a while; the card keeps showing progress and the log +lands in `~/.config/punktfunk/logs/update-steamos.log`. + ## Turning the check off The check contacts `git.unom.io` (the punktfunk forge) and nothing else, and sends nothing but a diff --git a/web/src/sections/Host/UpdateCard.tsx b/web/src/sections/Host/UpdateCard.tsx index f1593ce9..4caa8190 100644 --- a/web/src/sections/Host/UpdateCard.tsx +++ b/web/src/sections/Host/UpdateCard.tsx @@ -396,7 +396,9 @@ const ApplyProgress: FC<{ /> )} - {timedOut && ( + {/* A live job (e.g. the Deck's tens-of-minutes source rebuild) is not "timed out" — + the warning is for the host being GONE longer than a restart explains. */} + {timedOut && !job && (

{m.update_apply_timeout()}