From ae394ac4acc819ba92c49adbf56cf1efd4948156 Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:45:17 +0000 Subject: [PATCH 1/3] Fix OS update reboot, confirmation and retry lifecycle --- .github/workflows/build.yml | 3 + .github/workflows/validate.yml | 1 + client/src/main.rs | 3 + client/src/platform/linux/os_journal.rs | 149 +++++++++++ client/src/platform/linux/os_update.rs | 233 +++++++++++++++--- client/src/platform/linux/ui.rs | 70 ++++-- client/src/platform/linux/update_guard.rs | 26 +- .../01-install-kiosk/01-run-chroot.sh | 1 + deploy/rauc/UPDATE-LIFECYCLE.md | 65 +++++ deploy/rauc/betterframe-rauc-boot-grub.sh | 2 +- deploy/rauc/betterframe-rauc-boot-systemd.sh | 2 +- deploy/rauc/betterframe-rauc-boot.sh | 2 +- deploy/rauc/build-bundle.sh | 2 + deploy/rauc/hook.sh | 20 +- deploy/rauc/reboot-after-install.sh | 63 +++++ deploy/rauc/system-x86.conf | 2 +- deploy/rauc/system.conf | 2 +- deploy/rauc/tests/test_update_lifecycle.py | 149 +++++++++++ deploy/scripts/setup-pi-kiosk.sh | 1 + .../betterframe-rauc-mark-good.service | 3 + deploy/systemd/betterframe-rauc-mark-good.sh | 4 +- deploy/systemd/betterframe-rauc-state.sh | 18 ++ deploy/systemd/rauc.service | 2 + deploy/x86-image/build-image.sh | 2 + server/src/plugins/service-api-http/index.ts | 11 +- server/src/shared/os-update-status.ts | 32 +++ server/src/web-templates/admin-pages.tsx | 11 +- server/tests/offline-kiosk.test.ts | 9 +- server/tests/os-update-status.test.ts | 74 ++++++ 29 files changed, 880 insertions(+), 82 deletions(-) create mode 100644 client/src/platform/linux/os_journal.rs create mode 100644 deploy/rauc/UPDATE-LIFECYCLE.md create mode 100644 deploy/rauc/reboot-after-install.sh create mode 100644 deploy/rauc/tests/test_update_lifecycle.py create mode 100644 deploy/systemd/betterframe-rauc-state.sh create mode 100644 server/src/shared/os-update-status.ts create mode 100644 server/tests/os-update-status.test.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ee3df224..084868bb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -62,6 +62,8 @@ jobs: with: ref: ${{ inputs.ref }} - uses: dtolnay/rust-toolchain@stable + - name: Test OS update boot safety + run: python3 -m unittest discover -s deploy/rauc/tests -v - name: Test shared client behavior run: cargo test --manifest-path client/Cargo.toml -p betterframe-client-core --locked @@ -485,6 +487,7 @@ jobs: deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/ cp deploy/systemd/betterframe-rauc-mark-good.sh \ deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/ + cp deploy/systemd/betterframe-rauc-state.sh deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/ cp deploy/tmpfiles/betterframe-kiosk.conf \ deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/ cp deploy/udev/90-betterframe-no-hid.rules \ diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 635728e8..adef200f 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -21,6 +21,7 @@ jobs: node-version: 24 - run: python3 scripts/test-release-versions.py - run: python3 scripts/test-easy1-deploy.py + - run: python3 -m unittest discover -s deploy/rauc/tests -v server: runs-on: ubuntu-24.04 diff --git a/client/src/main.rs b/client/src/main.rs index 9551dcfc..76dfcae9 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -39,6 +39,9 @@ mod operator_console; #[path = "platform/linux/os_update.rs"] mod os_update; #[cfg(target_os = "linux")] +#[path = "platform/linux/os_journal.rs"] +mod os_journal; +#[cfg(target_os = "linux")] #[path = "platform/linux/pipeline.rs"] mod pipeline; #[cfg(target_os = "linux")] diff --git a/client/src/platform/linux/os_journal.rs b/client/src/platform/linux/os_journal.rs new file mode 100644 index 00000000..8e59a821 --- /dev/null +++ b/client/src/platform/linux/os_journal.rs @@ -0,0 +1,149 @@ +//! Durable evidence of an OS attempt, independent of the replaceable root slot. +use serde::{Deserialize, Serialize}; +use std::{fs, io::Write, path::Path}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Stage { + Downloading, + Installing, + PendingReboot, + Failed, + RolledBack, + Confirmed, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Journal { + pub version: String, + pub release_id: String, + pub boot_id: String, + pub stage: Stage, + pub error: Option, +} + +impl Journal { + /// Reconcile only after a boot change, never infer rollback from a heartbeat + /// sent by the old OS while installation is in progress. + pub fn reconcile(&mut self, boot_id: &str, running: &str, healthy: bool) { + if self.boot_id == boot_id { + return; + } + if matches!( + self.stage, + Stage::Installing | Stage::PendingReboot | Stage::Confirmed + ) { + if self.version == running { + self.stage = if healthy { + Stage::Confirmed + } else { + Stage::PendingReboot + }; + self.error = None; + } else { + self.stage = Stage::RolledBack; + self.error = Some(format!( + "Update {} was interrupted or rolled back; running OS {running}. Retry from BetterFrame.", + self.version + )); + } + } + } + pub fn blocks_apply(&self) -> bool { + matches!( + self.stage, + Stage::Installing | Stage::PendingReboot | Stage::RolledBack + ) + } +} + +pub fn read(path: &Path) -> Result, String> { + match fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map(Some) + .map_err(|e| format!("Invalid OS update record: {e}")), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(format!("Read OS update record: {e}")), + } +} + +pub fn write(path: &Path, journal: &Journal) -> Result<(), String> { + let parent = path.parent().ok_or("Missing journal directory")?; + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + let tmp = path.with_extension("json.tmp"); + let bytes = serde_json::to_vec(journal).map_err(|e| e.to_string())?; + let mut file = fs::File::create(&tmp).map_err(|e| e.to_string())?; + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|e| e.to_string())?; + fs::rename(&tmp, path).map_err(|e| e.to_string())?; + fs::File::open(parent) + .and_then(|f| f.sync_all()) + .map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn attempt(stage: Stage) -> Journal { + Journal { + version: "1.0.0".into(), + release_id: "release".into(), + boot_id: "old".into(), + stage, + error: None, + } + } + #[test] + fn old_heartbeat_is_not_rollback() { + let mut j = attempt(Stage::PendingReboot); + j.reconcile("old", "0.318", true); + assert_eq!(j.stage, Stage::PendingReboot); + assert!(j.blocks_apply()); + } + #[test] + fn reboot_into_old_slot_preserves_failed_target() { + let mut j = attempt(Stage::PendingReboot); + j.reconcile("new", "0.318", true); + assert_eq!(j.stage, Stage::RolledBack); + assert_eq!(j.version, "1.0.0"); + assert!(j.blocks_apply()); + } + #[test] + fn new_slot_requires_health_confirmation() { + let mut j = attempt(Stage::Installing); + j.reconcile("new", "1.0.0", false); + assert_eq!(j.stage, Stage::PendingReboot); + assert!(j.blocks_apply()); + } + #[test] + fn interrupted_download_can_resume() { + let mut j = attempt(Stage::Downloading); + j.reconcile("new", "0.318", true); + assert!(!j.blocks_apply()); + } + #[test] + fn unpaired_healthy_boot_unblocks_future_updates() { + let mut j = attempt(Stage::PendingReboot); + j.reconcile("new", "1.0.0", true); + assert_eq!(j.stage, Stage::Confirmed); + assert!(!j.blocks_apply()); + } + #[test] + fn previously_confirmed_version_cannot_hide_later_fallback() { + let mut j = attempt(Stage::Confirmed); + j.reconcile("new", "0.318", true); + assert_eq!(j.stage, Stage::RolledBack); + assert_eq!(j.version, "1.0.0"); + } + #[test] + fn durable_record_roundtrip_and_corruption() { + let dir = std::env::temp_dir().join(format!("bf-os-journal-{}", std::process::id())); + let path = dir.join("attempt.json"); + write(&path, &attempt(Stage::Installing)).unwrap(); + assert_eq!(read(&path).unwrap().unwrap().stage, Stage::Installing); + fs::write(&path, "broken").unwrap(); + assert!(read(&path).is_err()); + fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/client/src/platform/linux/os_update.rs b/client/src/platform/linux/os_update.rs index d0d224f3..707a04ec 100644 --- a/client/src/platform/linux/os_update.rs +++ b/client/src/platform/linux/os_update.rs @@ -28,7 +28,47 @@ //! at image build time). Falls back to env `BF_RAUC_COMPATIBILITY`, then //! a hardcoded default matching deploy/rauc/system.conf. +use crate::os_journal::{self, Journal, Stage}; use std::fs; +use std::sync::Mutex; +const JOURNAL_PATH: &str = "/var/lib/betterframe/kiosk/os-update.json"; +static JOURNAL_LOCK: Mutex<()> = Mutex::new(()); +fn boot_id() -> String { + fs::read_to_string("/proc/sys/kernel/random/boot_id") + .unwrap_or_default() + .trim() + .to_string() +} + +pub fn last_update_note() -> Option { + let _lock = JOURNAL_LOCK.lock().ok()?; + match os_journal::read(std::path::Path::new(JOURNAL_PATH)) { + Ok(Some(j)) => j.error.clone().or_else(|| { + if j.blocks_apply() { + Some(format!( + "OS update {} awaits boot confirmation. Manage updates in BetterFrame.", + j.version + )) + } else { + None + } + }), + Err(e) => Some(e), + _ => None, + } +} + +fn save_stage(stage: Stage, error: Option) -> Result<(), String> { + let _lock = JOURNAL_LOCK.lock().map_err(|_| "OS update record locked")?; + let path = std::path::Path::new(JOURNAL_PATH); + if let Some(mut j) = os_journal::read(path)? { + j.stage = stage; + j.error = error; + os_journal::write(path, &j)?; + } + Ok(()) +} + use std::io::Read; use std::path::PathBuf; use std::process::{Command, Stdio}; @@ -41,6 +81,19 @@ use tracing::{info, warn}; pub const DEFAULT_COMPATIBILITY: &str = "betterframe-rpi5-aarch64"; static CANCEL_REQUESTED: AtomicBool = AtomicBool::new(false); +fn installer_idle() -> bool { + Command::new("busctl") + .args([ + "get-property", + "de.pengutronix.rauc", + "/", + "de.pengutronix.rauc.Installer", + "Operation", + ]) + .output() + .map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).trim() == "s \"idle\"") + .unwrap_or(false) +} pub fn compatibility_public() -> String { compatibility() @@ -57,8 +110,9 @@ fn compatibility() -> String { } pub fn request_cancel() { + // Only the active downloader may discard its file. A channel change must + // never unlink a bundle that the root RAUC daemon might still be using. CANCEL_REQUESTED.store(true, Ordering::SeqCst); - cleanup_partial_update(); } pub fn clear_cancel() { @@ -69,19 +123,6 @@ fn cancel_requested() -> bool { CANCEL_REQUESTED.load(Ordering::SeqCst) } -fn cleanup_partial_update() { - let staging = PathBuf::from("/var/lib/betterframe/tmp"); - let Ok(entries) = fs::read_dir(staging) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|ext| ext.to_str()) == Some("raucb") { - let _ = fs::remove_file(path); - } - } -} - pub fn current_os_version_public() -> String { current_os_version() } @@ -170,8 +211,9 @@ pub fn apply( key: &str, info: &UpdateInfo, on_progress: impl Fn(&str, u8), + force: bool, ) -> Result<(), String> { - apply_inner(server, Some(key), info, on_progress) + apply_tracked(server, Some(key), info, on_progress, force) } pub fn apply_public( @@ -179,7 +221,72 @@ pub fn apply_public( info: &UpdateInfo, on_progress: impl Fn(&str, u8), ) -> Result<(), String> { - apply_inner(server, None, info, on_progress) + apply_tracked(server, None, info, on_progress, false) +} + +fn apply_tracked( + server: &str, + key: Option<&str>, + info: &UpdateInfo, + on_progress: impl Fn(&str, u8), + force: bool, +) -> Result<(), String> { + ensure_upgrade(info, ¤t_os_version())?; + let id = boot_id(); + if id.is_empty() { + return Err("Cannot identify this boot; OS update deferred".into()); + } + { + let _lock = JOURNAL_LOCK.lock().map_err(|_| "OS update record locked")?; + let path = std::path::Path::new(JOURNAL_PATH); + if let Some(mut j) = os_journal::read(path)? { + j.reconcile(&id, ¤t_os_version(), boot_is_confirmed()); + os_journal::write(path, &j)?; + if j.blocks_apply() && force && !installer_idle() { + return Err( + "OS installer is still busy or unavailable; retry after installation finishes" + .into(), + ); + } + if j.blocks_apply() && !force { + return Err(j.error.unwrap_or_else(|| format!("OS update {} is awaiting reboot/confirmation; retry from BetterFrame if needed", j.version))); + } + } + if let Some(n) = crate::update_guard::blocked("os", &info.version, force) { + return Err(format!( + "OS update {} paused after {n} attempts. Pair this display and retry from BetterFrame.", + info.version + )); + } + os_journal::write( + path, + &Journal { + version: info.version.clone(), + release_id: info.release_id.clone(), + boot_id: id, + stage: Stage::Downloading, + error: None, + }, + )?; + // Count before starting so power loss also consumes an attempt. + crate::update_guard::record_attempt("os", &info.version)?; + } + let result = apply_inner(server, key, info, on_progress); + if let Err(ref error) = result { + let _lock = JOURNAL_LOCK.lock().map_err(|_| "OS update record locked")?; + let path = std::path::Path::new(JOURNAL_PATH); + if let Some(mut j) = os_journal::read(path)? { + // An installed slot is never downloaded again automatically while + // the reboot outcome remains unknown. + if !matches!(j.stage, Stage::Installing | Stage::PendingReboot) { + j.stage = Stage::Failed; + } + j.error = Some(error.chars().take(4000).collect()); + os_journal::write(path, &j)?; + } + let _ = report_applied(server, key, &info.version, Some(error)); + } + result } fn apply_inner( @@ -385,6 +492,10 @@ fn apply_inner( // Hand off to rauc. `rauc install` blocks until the bundle is fully // copied into the inactive slot and bootloader is flipped. Exit code 0 // = success; anything else = leave current slot booted, no reboot. + if !installer_idle() { + return Err("OS installer is busy or unavailable; installation deferred".into()); + } + save_stage(Stage::Installing, None)?; let mut child = Command::new("rauc") .args(["install", bundle_path.to_str().unwrap_or("")]) .stdout(Stdio::piped()) @@ -417,25 +528,14 @@ fn apply_inner( let _ = child_stderr.read_to_end(&mut bytes); bytes }); + // Once RAUC has accepted the install, cancelling its CLI cannot cancel + // the root daemon. Finish observing the transaction before retry/reboot. let status = loop { - if cancel_requested() { - let _ = child.kill(); - let _ = child.wait(); - let _ = report_applied( - server, - key, - &info.version, - Some("os update canceled after channel change"), - ); - let _ = fs::remove_file(&bundle_path); - return Err("os update canceled after channel change".to_string()); - } match child.try_wait() { Ok(Some(status)) => break status, Ok(None) => std::thread::sleep(Duration::from_secs(1)), Err(e) => { - let _ = fs::remove_file(&bundle_path); - return Err(format!("rauc wait: {e}")); + return Err(format!("RAUC outcome unknown; bundle retained: {e}")); } } }; @@ -445,7 +545,8 @@ fn apply_inner( let msg = format_command_failure("rauc install", status, &stdout, &stderr); warn!("os-update: {msg}"); let _ = report_applied(server, key, &info.version, Some(&msg)); - let _ = fs::remove_file(&bundle_path); + // The CLI may have lost D-Bus while the daemon continues. Keep the + // bundle and blocked Installing record until an explicit retry. return Err(msg); } let _ = fs::remove_file(&bundle_path); @@ -455,15 +556,20 @@ fn apply_inner( // the next heartbeat anyway, but recording success now means the // admin UI shows progress immediately. let _ = report_applied(server, key, &info.version, None); - crate::update_guard::record_success("os", &info.version); + save_stage(Stage::PendingReboot, None)?; on_progress("Rebooting", 100); info!("os-update: rauc install OK → rebooting into the new slot"); // The root-run RAUC hook schedules the reboot after it finishes patching // the target slot. The kiosk service has NoNewPrivileges=yes for WebKit, // so attempting sudo here can never work. - std::thread::sleep(Duration::from_secs(60)); - let message = "scheduled reboot did not occur".to_string(); + std::thread::sleep(Duration::from_secs(180)); + let detail = fs::read_to_string("/run/betterframe-rauc/os-reboot-status.txt") + .unwrap_or_else(|_| "No reboot-service diagnostics available from this OS".into()); + let message = format!( + "OS installed but reboot was not confirmed: {}. Retry from BetterFrame.", + detail.trim() + ); let _ = report_applied(server, key, &info.version, Some(&message)); Err(message) } @@ -512,21 +618,68 @@ fn report_applied( .json(&payload) .timeout(Duration::from_secs(5)) .send() + .and_then(|response| response.error_for_status()) .map(|_| ()) .map_err(|e| format!("report applied: {e}")) } pub fn report_confirmed(server: &str, key: &str) -> bool { - let version = - fs::read_to_string("/etc/betterframe/os-version").unwrap_or_else(|_| "unknown".to_string()); - crate::network::blocking_client() + let Ok(_lock) = JOURNAL_LOCK.lock() else { + return false; + }; + let path = std::path::Path::new(JOURNAL_PATH); + let running = current_os_version(); + let id = boot_id(); + if running.is_empty() || id.is_empty() { + return false; + } + let mut journal = match os_journal::read(path) { + Ok(j) => j, + Err(_) => return false, + }; + let mut version = running.clone(); + let mut state = "confirmed"; + let mut error = None; + if let Some(j) = journal.as_mut() { + j.reconcile(&id, &running, boot_is_confirmed()); + version = j.version.clone(); + match j.stage { + Stage::Downloading | Stage::Installing => return false, + Stage::PendingReboot if j.boot_id == id => return false, + Stage::RolledBack => { + state = "rolled_back"; + error = j.error.clone(); + } + Stage::Failed => { + state = "failed"; + error = j.error.clone(); + } + _ => { + if !boot_is_confirmed() { + return false; + } + j.stage = Stage::Confirmed; + j.error = None; + } + } + if os_journal::write(path, j).is_err() { + return false; + } + } else if !boot_is_confirmed() { + return false; + } + let ok = crate::network::blocking_client() .post(format!("{server}/api/kiosk/os/status")) .header("Authorization", format!("Bearer {key}")) - .json(&serde_json::json!({ "version": version.trim(), "state": "confirmed" })) + .json(&serde_json::json!({ "version": version, "state": state, "error": error })) .timeout(Duration::from_secs(5)) .send() - .map(|response| response.status().is_success()) - .unwrap_or(false) + .map(|r| r.status().is_success()) + .unwrap_or(false); + if ok && state == "confirmed" { + crate::update_guard::record_success("os", &version); + } + ok } pub fn boot_is_confirmed() -> bool { diff --git a/client/src/platform/linux/ui.rs b/client/src/platform/linux/ui.rs index 29e503be..f96aa477 100644 --- a/client/src/platform/linux/ui.rs +++ b/client/src/platform/linux/ui.rs @@ -256,22 +256,6 @@ fn activate(app: &Application) { } } } - if server::ota_enabled("BF_ENABLE_OS_OTA") { - let _ = tx.send(WorkerMsg::StartupStatus("Checking for OS updates".into())); - if let Some(update) = os_update::check_public(&server) { - let version = update.version.clone(); - let tx_progress = tx.clone(); - if let Err(e) = os_update::apply_public(&server, &update, move |phase, pct| { - let _ = tx_progress.send(WorkerMsg::UpdateProgress(Some(( - format!("OS Update {version}: {phase}"), - pct, - )))); - }) { - let _ = tx.send(WorkerMsg::UpdateProgress(None)); - tracing::warn!("preboot OS update failed: {e}"); - } - } - } } let key = if server::is_paired() { @@ -303,6 +287,31 @@ fn activate(app: &Application) { } }; let _ = tx.send(WorkerMsg::ShowPairingCode(session.code.clone())); + // Let the displayed pairing screen confirm this boot before + // replacing another slot. Pairing health is independent of claim. + for _ in 0..15 { + if os_update::boot_is_confirmed() { + break; + } + std::thread::sleep(Duration::from_secs(2)); + } + if server::ota_enabled("BF_ENABLE_OS_OTA") && os_update::boot_is_confirmed() { + let _ = tx.send(WorkerMsg::StartupStatus("Checking for OS updates".into())); + if let Some(update) = os_update::check_public(&server) { + let version = update.version.clone(); + let tx_progress = tx.clone(); + if let Err(e) = os_update::apply_public(&server, &update, move |phase, pct| { + let _ = tx_progress.send(WorkerMsg::UpdateProgress(Some(( + format!("OS Update {version}: {phase}"), + pct, + )))); + }) { + let _ = tx.send(WorkerMsg::UpdateProgress(None)); + tracing::warn!("preboot OS update failed: {e}"); + } + } + } + let _ = tx.send(WorkerMsg::ShowPairingCode(session.code.clone())); if let Some((name, key)) = server::poll_claim_until_expiry(&server, &session, |status| { let _ = tx.send(WorkerMsg::PairingStatus( @@ -558,7 +567,7 @@ fn activate(app: &Application) { apply_boot_audio_default(); first_iter = false; } - if heartbeat_ok && !confirmation_reported && os_update::boot_is_confirmed() { + if heartbeat_ok && !confirmation_reported { confirmation_reported = os_update::report_confirmed(&server, &key); } if server::auto_updates_allowed() { @@ -914,6 +923,10 @@ fn maybe_apply_os_update( info!("os-update: disabled (BF_ENABLE_OS_OTA = 0)"); return; } + if !os_update::boot_is_confirmed() { + info!("os-update: waiting for current boot health confirmation"); + return; + } if OS_UPDATE_ACTIVE .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) .is_err() @@ -980,14 +993,20 @@ fn maybe_apply_os_update( } let version = info.version.clone(); let tx_cb = tx.clone(); - let result = os_update::apply(&server_url, &kiosk_key, &info, move |phase, pct| { - let label = format!("OS Update {version}: {phase}"); - let _ = tx_cb.send(WorkerMsg::UpdateProgress(Some((label, pct)))); - }); + let result = os_update::apply( + &server_url, + &kiosk_key, + &info, + move |phase, pct| { + let label = format!("OS Update {version}: {phase}"); + let _ = tx_cb.send(WorkerMsg::UpdateProgress(Some((label, pct)))); + }, + force, + ); UPDATE_APPLY_ACTIVE.store(false, Ordering::SeqCst); OS_UPDATE_ACTIVE.store(false, Ordering::SeqCst); if let Err(err) = result { - let failures = crate::update_guard::record_failure("os", &info.version, &err); + let failures = crate::update_guard::failure_count("os", &info.version); let _ = tx.send(WorkerMsg::UpdateProgress(None)); warn!("os-update: apply failed: {err}"); server::report_kiosk_log( @@ -1320,6 +1339,12 @@ fn show_pairing_code(window: &ApplicationWindow, code: &str, status: &str) { vbox.append(&title); vbox.append(&code_label); vbox.append(&hint); + if let Some(note) = os_update::last_update_note() { + let diagnostic = Label::new(Some(¬e)); + diagnostic.set_wrap(true); + diagnostic.set_max_width_chars(72); + vbox.append(&diagnostic); + } let fw_ver = server::kiosk_app_version(); let os_ver = @@ -1340,6 +1365,7 @@ fn show_pairing_code(window: &ApplicationWindow, code: &str, status: &str) { window.set_child(Some(&overlay)); window.queue_resize(); window.queue_draw(); + mark_kiosk_healthy(); info!("pairing display updated to {code}"); } diff --git a/client/src/platform/linux/update_guard.rs b/client/src/platform/linux/update_guard.rs index 7c799bdc..afa04d7b 100644 --- a/client/src/platform/linux/update_guard.rs +++ b/client/src/platform/linux/update_guard.rs @@ -35,6 +35,21 @@ pub fn blocked(kind: &str, version: &str, force: bool) -> Option { (failures >= ATTEMPT_LIMIT).then_some(failures) } +/// Persist an attempt before starting work, including attempts interrupted by power loss. +pub fn record_attempt(kind: &str, version: &str) -> Result { + let _lock = GUARD_LOCK + .lock() + .map_err(|_| "Update attempt record locked")?; + let mut state = read_state(); + let entry = state.entries.entry(key(kind, version)).or_default(); + entry.failures = entry.failures.saturating_add(1); + entry.last_error = Some("Attempt started; awaiting boot confirmation".into()); + entry.last_failed_at = now_secs(); + let attempts = entry.failures; + write_state(&state)?; + Ok(attempts) +} + pub fn record_failure(kind: &str, version: &str, err: &str) -> u32 { let _lock = GUARD_LOCK.lock().ok(); let mut state = read_state(); @@ -85,7 +100,16 @@ fn read_state() -> AttemptState { fn write_state(state: &AttemptState) -> Result<(), String> { fs::create_dir_all("/var/lib/betterframe/kiosk").map_err(|e| format!("mkdir: {e}"))?; let raw = serde_json::to_string(state).map_err(|e| format!("encode: {e}"))?; - fs::write(ATTEMPT_FILE, raw).map_err(|e| format!("write: {e}")) + use std::io::Write; + let tmp = format!("{ATTEMPT_FILE}.tmp"); + let mut file = fs::File::create(&tmp).map_err(|e| format!("create: {e}"))?; + file.write_all(raw.as_bytes()) + .and_then(|_| file.sync_all()) + .map_err(|e| format!("write: {e}"))?; + fs::rename(tmp, ATTEMPT_FILE).map_err(|e| format!("rename: {e}"))?; + fs::File::open("/var/lib/betterframe/kiosk") + .and_then(|f| f.sync_all()) + .map_err(|e| format!("sync directory: {e}")) } fn truncate(value: &str, max_chars: usize) -> String { diff --git a/deploy/pi-gen/stage-betterframe-client/01-install-kiosk/01-run-chroot.sh b/deploy/pi-gen/stage-betterframe-client/01-install-kiosk/01-run-chroot.sh index 76de277e..34d41e97 100755 --- a/deploy/pi-gen/stage-betterframe-client/01-install-kiosk/01-run-chroot.sh +++ b/deploy/pi-gen/stage-betterframe-client/01-install-kiosk/01-run-chroot.sh @@ -105,6 +105,7 @@ install -m 755 /tmp/bf-files/betterframe-rauc-boot.sh /usr/local/sbin/betterfram # RAUC's Debian package ships without systemd unit + D-Bus activation files. # Without these, `rauc install` and `rauc status` fail because the D-Bus # daemon name de.pengutronix.rauc is never registered. +install -m 755 /tmp/bf-files/betterframe-rauc-state.sh /usr/local/sbin/betterframe-rauc-state.sh install -m 644 /tmp/bf-files/rauc.service /etc/systemd/system/rauc.service install -d -m 755 /usr/share/dbus-1/system-services install -m 644 /tmp/bf-files/de.pengutronix.rauc.service \ diff --git a/deploy/rauc/UPDATE-LIFECYCLE.md b/deploy/rauc/UPDATE-LIFECYCLE.md new file mode 100644 index 00000000..8a301d55 --- /dev/null +++ b/deploy/rauc/UPDATE-LIFECYCLE.md @@ -0,0 +1,65 @@ +# OS update lifecycle + +A slot post-install hook is not a completed installation. Activation and final +RAUC status writes happen after the hook returns. The bundle therefore stages a +root-owned reboot guard in `/run` and starts it through systemd. This works when +the currently running kiosk predates the fix and cannot request a privileged +reboot itself. It does not grant the kiosk additional privileges. + +The guard captures the RAUC D-Bus owner during installation and requires the same +daemon to finish with `Operation=idle` and an empty `LastError`. The guard allows +up to 30 minutes for remaining slot writes and cleanup on slow storage; this wait +is independent of the client watchdog, which starts after RAUC returns. It then verifies +activation: `GetPrimary` identifies the target on x86; the Pi backend's `pending` +record is written only after both tryboot configurations have been synced. Pi +`GetPrimary` identifies the permanent slot, so it cannot confirm trial activation. +The Pi argument form follows the [Raspberry Pi documentation](https://www.raspberrypi.com/documentation/hardware/rpi/os.html). +The guard rechecks the daemon and idle/error state after its five-second grace +period before requesting x86 reboot or Pi `reboot '0 tryboot'` (one argument). Failed, restarted, +unactivated, or timed-out transactions do not trigger reboot. + +The guard exposes its current stage or failure in the root-owned, readable file +`/run/betterframe-rauc/os-reboot-status.txt`. The kiosk's persisted update attempt +and admin status remain the user-facing source of diagnostics. No shell access +is required on managed devices. + +RAUC metadata lives on shared BF_DATA at `/var/lib/betterframe/rauc`, rather than +inside a replaceable root slot. The service migrates legacy metadata once before +starting. When installing from an older OS, the bundled guard refreshes legacy +metadata after activation so the new system receives the final pending state. +RAUC cannot start before the data mount is available. + +The health confirmation service retries after a five-minute health wait instead +of permanently abandoning confirmation. The kiosk must supply a real rendered +health marker, including for its unpaired pairing screen. Only successful +`rauc status mark-good` produces the confirmation marker. + +Run host regression tests without hardware or any real reboot: + +```sh +python3 -m unittest discover -s deploy/rauc/tests -v +``` + +These tests mock D-Bus, reboot, and systemd. Actual bootloader behavior and power +loss during writes still require Pi and x86 hardware testing. Existing downloaded +bundles retain their old hook; the correction takes effect with a new bundle. + +The client stores its target release, boot ID, phase and diagnostic in +`/var/lib/betterframe/kiosk/os-update.json`, using an atomic, synced replacement. +A new boot is reconciled against `/etc/betterframe/os-version` and RAUC's health +confirmation. An unpaired healthy boot can confirm locally; reporting to BF is +separate. A return to the older OS preserves the attempted target and reports a +rollback instead of overwriting it with an unrelated confirmation. + +Automatic and pre-pairing attempts share the persistent three-attempt limit. +Installing and pending-reboot records block automatic reinstallation. An admin +retry is allowed only once the installer is idle. Changing channels can cancel a +download, but cannot abort a transaction already handed to RAUC; loss of the CLI +connection keeps the bundle and blocks automatic retries because the daemon may +still be writing it. Errors appear on the pairing screen and in BF after pairing. + +The signed **next OS bundle** carries the reboot guard, including the corrected +Pi argument, for installation by 0.318 or 1.0.0 clients. Already published +1.0.0 bundles are immutable and retain the faulty hook. New client lifecycle +tracking and pairing-health confirmation take effect after booting the new OS. +No terminal access or signing-key rotation is part of this recovery path. diff --git a/deploy/rauc/betterframe-rauc-boot-grub.sh b/deploy/rauc/betterframe-rauc-boot-grub.sh index 13735fd3..883d35af 100755 --- a/deploy/rauc/betterframe-rauc-boot-grub.sh +++ b/deploy/rauc/betterframe-rauc-boot-grub.sh @@ -3,7 +3,7 @@ set -euo pipefail BOOT_A_DEV="${BF_RAUC_BOOT_A_DEV:-/dev/disk/by-partlabel/BF_BOOT_A}" BOOT_B_DEV="${BF_RAUC_BOOT_B_DEV:-/dev/disk/by-partlabel/BF_BOOT_B}" -STATE_DIR="${BF_RAUC_STATE_DIR:-/var/lib/rauc/betterframe}" +STATE_DIR="${BF_RAUC_STATE_DIR:-/var/lib/betterframe/rauc/betterframe}" STATE_FILE="${STATE_DIR}/slot-state" slot_to_boot_dev() { diff --git a/deploy/rauc/betterframe-rauc-boot-systemd.sh b/deploy/rauc/betterframe-rauc-boot-systemd.sh index 07b1f833..ed76c95a 100755 --- a/deploy/rauc/betterframe-rauc-boot-systemd.sh +++ b/deploy/rauc/betterframe-rauc-boot-systemd.sh @@ -3,7 +3,7 @@ set -euo pipefail BOOT_A_DEV="${BF_RAUC_BOOT_A_DEV:-/dev/disk/by-partlabel/BF_BOOT_A}" BOOT_B_DEV="${BF_RAUC_BOOT_B_DEV:-/dev/disk/by-partlabel/BF_BOOT_B}" -STATE_DIR="${BF_RAUC_STATE_DIR:-/var/lib/rauc/betterframe}" +STATE_DIR="${BF_RAUC_STATE_DIR:-/var/lib/betterframe/rauc/betterframe}" STATE_FILE="${STATE_DIR}/slot-state" slot_to_entry() { diff --git a/deploy/rauc/betterframe-rauc-boot.sh b/deploy/rauc/betterframe-rauc-boot.sh index ae9debfc..4e9578de 100644 --- a/deploy/rauc/betterframe-rauc-boot.sh +++ b/deploy/rauc/betterframe-rauc-boot.sh @@ -3,7 +3,7 @@ set -euo pipefail BOOT_A_DEV="${BF_RAUC_BOOT_A_DEV:-/dev/disk/by-partlabel/BF_BOOT_A}" BOOT_B_DEV="${BF_RAUC_BOOT_B_DEV:-/dev/disk/by-partlabel/BF_BOOT_B}" -STATE_DIR="${BF_RAUC_STATE_DIR:-/var/lib/rauc/betterframe}" +STATE_DIR="${BF_RAUC_STATE_DIR:-/var/lib/betterframe/rauc/betterframe}" STATE_FILE="${STATE_DIR}/slot-state" slot_to_part() { diff --git a/deploy/rauc/build-bundle.sh b/deploy/rauc/build-bundle.sh index 3df7b154..40ffc7d4 100755 --- a/deploy/rauc/build-bundle.sh +++ b/deploy/rauc/build-bundle.sh @@ -43,6 +43,8 @@ fi # it the slot images carry stale build-time references and the device # drops to initramfs after activation. cp "${SCRIPT_DIR}/hook.sh" "${STAGE}/hook.sh" +cp "${SCRIPT_DIR}/reboot-after-install.sh" "${STAGE}/reboot-after-install.sh" +cp "${SCRIPT_DIR}/../systemd/betterframe-rauc-state.sh" "${STAGE}/betterframe-rauc-state.sh" chmod +x "${STAGE}/hook.sh" echo "==> Rendering manifest" diff --git a/deploy/rauc/hook.sh b/deploy/rauc/hook.sh index 5ce0c1ee..5d564436 100755 --- a/deploy/rauc/hook.sh +++ b/deploy/rauc/hook.sh @@ -61,7 +61,7 @@ write_x86_rauc_system_conf() { compatible=betterframe-x86_64-generic bootloader=grub grubenv=/boot/efi/EFI/betterframe/grubenv -data-directory=/var/lib/rauc +data-directory=/var/lib/betterframe/rauc bundle-formats=plain [keyring] @@ -81,12 +81,18 @@ RAUCCONF } schedule_reboot() { - local -a command=(/usr/bin/systemctl reboot) - if [ "$1" = "pi" ]; then - command=(/usr/sbin/reboot 0 tryboot) - fi - systemd-run --unit=betterframe-rauc-reboot --on-active=30s --collect "${command[@]}" - echo "hook: scheduled reboot after successful RAUC install" + # This hook is still inside the install transaction. Start a root-owned + # guard, copied out of the bundle before RAUC unmounts it. Never reboot on + # a timer measured from an unfinished transaction. + local owner guard_dir + owner="$(busctl call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus GetNameOwner s de.pengutronix.rauc)" + [ "$(busctl get-property de.pengutronix.rauc / de.pengutronix.rauc.Installer Operation)" = 's "installing"' ] + guard_dir="$(mktemp -d /run/betterframe-rauc-reboot.XXXXXX)" + install -m 700 "$(dirname "$0")/reboot-after-install.sh" "$guard_dir/reboot.sh" + install -m 700 "$(dirname "$0")/betterframe-rauc-state.sh" "$guard_dir/state.sh" + systemd-run --unit=betterframe-rauc-reboot --collect \ + --property=RuntimeMaxSec=1900s "$guard_dir/reboot.sh" "$1" "$RAUC_SLOT_NAME" "$owner" + echo "hook: waiting for RAUC transaction completion before reboot" } LETTER="$(slot_letter)" diff --git a/deploy/rauc/reboot-after-install.sh b/deploy/rauc/reboot-after-install.sh new file mode 100644 index 00000000..03474d19 --- /dev/null +++ b/deploy/rauc/reboot-after-install.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Executed as root by systemd, independently of the confined kiosk and RAUC +# bundle mount. D-Bus contract: https://rauc.readthedocs.io/en/latest/reference.html +set -euo pipefail +platform="${1:?platform required}" +target="${2:?target slot required}" +owner="${3:?RAUC bus owner required}" +case "$platform:$target" in pi:rootfs.[01]|x86:rootfs.[01]) ;; *) exit 2 ;; esac +status_file="${BF_REBOOT_STATUS_FILE:-/run/betterframe-rauc/os-reboot-status.txt}" +install -d -m 755 "$(dirname "$status_file")" +status() { printf '%s\n' "$1" > "${status_file}.tmp"; chmod 644 "${status_file}.tmp"; mv "${status_file}.tmp" "$status_file"; } +fail() { status "$1"; echo "$1" >&2; exit 1; } +trap 'fail "Reboot guard failed; the device has not been rebooted"' ERR +same_daemon() { + [ "$(busctl call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus GetNameOwner s de.pengutronix.rauc)" = "$owner" ] +} +status 'Waiting for OS installation to finish' +deadline=$((SECONDS + ${BF_REBOOT_WAIT_SECONDS:-1800})) +while :; do + same_daemon || fail 'RAUC restarted during installation; reboot cancelled' + operation="$(busctl get-property de.pengutronix.rauc / de.pengutronix.rauc.Installer Operation)" + [ "$operation" != 's "idle"' ] || break + [ "$operation" = 's "installing"' ] || fail 'Unknown RAUC operation; reboot cancelled' + [ "$SECONDS" -lt "$deadline" ] || fail 'OS installation did not finish before reboot deadline' + sleep 1 +done +[ "$(busctl get-property de.pengutronix.rauc / de.pengutronix.rauc.Installer LastError)" = 's ""' ] || fail 'OS installation failed; reboot cancelled' +if [ "$platform" = x86 ]; then + [ "$(busctl call de.pengutronix.rauc / de.pengutronix.rauc.Installer GetPrimary)" = "s \"$target\"" ] || fail 'Updated OS slot was not activated; reboot cancelled' +else + # The Pi backend GetPrimary deliberately returns the permanent slot, not + # the trial slot. Its activation writes pending only AFTER both autoboot + # files have been written and synced. Use that completed activation record. + letter=A; [ "$target" != rootfs.1 ] || letter=B + state=/var/lib/betterframe/rauc/betterframe/slot-state + # Bundles must also work on old systems whose backend uses root-local state. + if ! grep -q '^data-directory=/var/lib/betterframe/rauc$' "${BF_RAUC_SYSTEM_CONF:-/etc/rauc/system.conf}"; then + state=/var/lib/rauc/betterframe/slot-state + fi + grep -qx "pending=$letter" "${BF_RAUC_ACTIVATION_STATE:-$state}" || fail 'Updated Pi trial slot was not activated; reboot cancelled' +fi +# Copy the legacy state AFTER activation; copying in the slot hook loses the +# pending/activated status written by RAUC when that hook returns. +if grep -q '^data-directory=/var/lib/betterframe/rauc$' "${BF_RAUC_SYSTEM_CONF:-/etc/rauc/system.conf}"; then + "$(dirname "$0")/state.sh" +else + "$(dirname "$0")/state.sh" --refresh-legacy +fi +same_daemon || fail 'RAUC restarted before reboot; reboot cancelled' +[ "$(busctl get-property de.pengutronix.rauc / de.pengutronix.rauc.Installer Operation)" = 's "idle"' ] || fail 'Another OS install started; reboot cancelled' +status 'OS installed; reboot requested' +sync +sleep "${BF_REBOOT_GRACE_SECONDS:-5}" +same_daemon || fail 'RAUC restarted during reboot grace period; reboot cancelled' +[ "$(busctl get-property de.pengutronix.rauc / de.pengutronix.rauc.Installer Operation)" = 's "idle"' ] || fail 'Another OS install started; reboot cancelled' +[ "$(busctl get-property de.pengutronix.rauc / de.pengutronix.rauc.Installer LastError)" = 's ""' ] || fail 'OS installation failed; reboot cancelled' +if [ "$platform" = pi ]; then + # Pi firmware reboot flags are one argument, including the embedded space: + # https://www.raspberrypi.com/documentation/hardware/rpi/os.html + reboot '0 tryboot' +else + systemctl reboot +fi diff --git a/deploy/rauc/system-x86.conf b/deploy/rauc/system-x86.conf index 62825569..10f53190 100644 --- a/deploy/rauc/system-x86.conf +++ b/deploy/rauc/system-x86.conf @@ -2,7 +2,7 @@ compatible=betterframe-x86_64-generic bootloader=grub grubenv=/boot/efi/EFI/betterframe/grubenv -data-directory=/var/lib/rauc +data-directory=/var/lib/betterframe/rauc bundle-formats=plain [keyring] diff --git a/deploy/rauc/system.conf b/deploy/rauc/system.conf index 94109cfb..293b0990 100644 --- a/deploy/rauc/system.conf +++ b/deploy/rauc/system.conf @@ -1,7 +1,7 @@ [system] compatible=betterframe-rpi5-aarch64 bootloader=custom -data-directory=/var/lib/rauc +data-directory=/var/lib/betterframe/rauc bundle-formats=plain [keyring] diff --git a/deploy/rauc/tests/test_update_lifecycle.py b/deploy/rauc/tests/test_update_lifecycle.py new file mode 100644 index 00000000..f83a122e --- /dev/null +++ b/deploy/rauc/tests/test_update_lifecycle.py @@ -0,0 +1,149 @@ +"""Host-only regression tests; reboot, D-Bus and systemd are always mocked.""" +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +DEPLOY = Path(__file__).resolve().parents[2] + + +class RebootGuardTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.bin = self.root / 'bin' + self.bin.mkdir() + self.log = self.root / 'calls' + self.status = self.root / 'status' + shutil.copyfile(DEPLOY / 'rauc/reboot-after-install.sh', self.root / 'guard.sh') + self.env = dict(os.environ, PATH=f'{self.bin}:{os.environ["PATH"]}', + BF_REBOOT_STATUS_FILE=str(self.status), BF_REBOOT_GRACE_SECONDS='0', + BF_REBOOT_WAIT_SECONDS='1', CALLS=str(self.log), + COUNT=str(self.root / 'count'), MODE='success', + BF_RAUC_SYSTEM_CONF=str(self.root / 'system.conf'), + BF_RAUC_ACTIVATION_STATE=str(self.root / 'slot-state')) + (self.root / 'system.conf').write_text('data-directory=/var/lib/betterframe/rauc\n') + (self.root / 'slot-state').write_text('pending=B\n') + self.script(self.root / 'state.sh', 'echo migrate >> "$CALLS"') + self.script(self.bin / 'busctl', ''' +case "${*: -1}" in + de.pengutronix.rauc) + [ "$MODE" != restart ] || { echo 's ":1.99"'; exit; } + echo 's ":1.42"' ;; + Operation) + n=$(cat "$COUNT" 2>/dev/null || echo 0); echo $((n+1)) > "$COUNT" + if [ "$MODE" = timeout ] || [ "$n" = 0 ]; then echo 's "installing"'; else echo 's "idle"'; fi ;; + LastError) + if [ "$MODE" = failed ]; then echo 's "activation failed"'; else echo 's ""'; fi ;; + GetPrimary) + if [ "$MODE" = wrongslot ]; then echo 's "rootfs.0"'; else echo 's "rootfs.1"'; fi ;; + *) exit 2 ;; +esac''') + self.script(self.bin / 'systemctl', 'echo "systemctl $*" >> "$CALLS"') + self.script(self.bin / 'reboot', '[ "$#" = 1 ] && [ "$1" = "0 tryboot" ]; echo "reboot argc=$# arg=$1" >> "$CALLS"') + self.script(self.bin / 'sync', ':') + + def script(self, path, body): + path.write_text('#!/usr/bin/env bash\nset -eu\n' + body + '\n') + path.chmod(0o755) + + def run_guard(self, mode='success', platform='x86'): + self.env['MODE'] = mode + return subprocess.run(['bash', str(self.root / 'guard.sh'), platform, 'rootfs.1', 's ":1.42"'], + env=self.env, capture_output=True, text=True, timeout=8) + + def test_x86_waits_for_completion_and_activation(self): + result = self.run_guard() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.log.read_text().splitlines(), ['migrate', 'systemctl reboot']) + self.assertGreaterEqual(int((self.root / 'count').read_text()), 3) + self.assertIn('reboot requested', self.status.read_text()) + + def test_pi_requests_tryboot_only_after_activation(self): + result = self.run_guard(platform='pi') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('reboot argc=1 arg=0 tryboot', self.log.read_text()) + + def test_pi_without_pending_slot_does_not_reboot(self): + (self.root / 'slot-state').write_text('pending=A\n') + self.assertNotEqual(self.run_guard(platform='pi').returncode, 0) + self.assertFalse(self.log.exists()) + + def test_transaction_failure_does_not_reboot(self): + self.assertNotEqual(self.run_guard('failed').returncode, 0) + self.assertFalse(self.log.exists()) + self.assertIn('installation failed', self.status.read_text()) + + def test_daemon_restart_does_not_reboot(self): + self.assertNotEqual(self.run_guard('restart').returncode, 0) + self.assertFalse(self.log.exists()) + + def test_unactivated_target_does_not_reboot(self): + self.assertNotEqual(self.run_guard('wrongslot').returncode, 0) + self.assertFalse(self.log.exists()) + + def test_busy_install_times_out_without_reboot(self): + self.assertNotEqual(self.run_guard('timeout').returncode, 0) + self.assertFalse(self.log.exists()) + self.assertIn('deadline', self.status.read_text()) + + +class MigrationTests(unittest.TestCase): + def test_migration_is_once_but_old_system_activation_refreshes(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + legacy, shared = root / 'legacy', root / 'shared' + legacy.mkdir() + (legacy / 'slot-state').write_text('pending=A') + env = dict(os.environ, BF_RAUC_LEGACY_DIR=str(legacy), BF_RAUC_SHARED_DIR=str(shared)) + command = ['bash', str(DEPLOY / 'systemd/betterframe-rauc-state.sh')] + subprocess.run(command, env=env, check=True) + (legacy / 'slot-state').write_text('pending=B') + subprocess.run(command, env=env, check=True) + self.assertEqual((shared / 'slot-state').read_text(), 'pending=A') + subprocess.run(command + ['--refresh-legacy'], env=env, check=True) + self.assertEqual((shared / 'slot-state').read_text(), 'pending=B') + + +class ConfirmationTests(unittest.TestCase): + def test_late_pairing_health_can_confirm_on_service_retry(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + marker, confirmed = root / 'healthy', root / 'confirmed' + rauc = root / 'rauc' + rauc.write_text('#!/bin/sh\n[ "$*" = "status mark-good" ]\n') + rauc.chmod(0o755) + env = dict(os.environ, PATH=f'{root}:{os.environ["PATH"]}', + BF_RAUC_HEALTH_MARKER=str(marker), BF_RAUC_CONFIRMED_MARKER=str(confirmed), + BF_RAUC_MARK_GOOD_TIMEOUT='0') + command = ['bash', str(DEPLOY / 'systemd/betterframe-rauc-mark-good.sh')] + self.assertNotEqual(subprocess.run(command, env=env, capture_output=True).returncode, 0) + self.assertFalse(confirmed.exists()) + marker.write_text('healthy') + env['BF_RAUC_MARK_GOOD_TIMEOUT'] = '1' + subprocess.run(command, env=env, check=True) + self.assertTrue(confirmed.exists()) + service = (DEPLOY / 'systemd/betterframe-rauc-mark-good.service').read_text() + self.assertIn('Restart=on-failure', service) + self.assertIn('RestartSec=10s', service) + + def test_failed_mark_good_never_claims_confirmation(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'healthy').write_text('healthy') + rauc = root / 'rauc' + rauc.write_text('#!/bin/sh\nexit 1\n') + rauc.chmod(0o755) + env = dict(os.environ, PATH=f'{root}:{os.environ["PATH"]}', + BF_RAUC_HEALTH_MARKER=str(root / 'healthy'), + BF_RAUC_CONFIRMED_MARKER=str(root / 'confirmed'), BF_RAUC_MARK_GOOD_TIMEOUT='1') + result = subprocess.run(['bash', str(DEPLOY / 'systemd/betterframe-rauc-mark-good.sh')], env=env) + self.assertNotEqual(result.returncode, 0) + self.assertFalse((root / 'confirmed').exists()) + + +if __name__ == '__main__': + unittest.main() diff --git a/deploy/scripts/setup-pi-kiosk.sh b/deploy/scripts/setup-pi-kiosk.sh index d4e13efd..0013c68a 100755 --- a/deploy/scripts/setup-pi-kiosk.sh +++ b/deploy/scripts/setup-pi-kiosk.sh @@ -341,6 +341,7 @@ for name in ['default','left_ptr','arrow','watch','hand2','text','xterm', /usr/local/sbin/betterframe-firmware-rollback.sh install -m 644 "${REPO_ROOT}/deploy/systemd/betterframe-rauc-mark-good.service" \ /etc/systemd/system/betterframe-rauc-mark-good.service + install -m 755 "${REPO_ROOT}/deploy/systemd/betterframe-rauc-state.sh" /usr/local/sbin/betterframe-rauc-state.sh install -m 755 "${REPO_ROOT}/deploy/systemd/betterframe-rauc-mark-good.sh" \ /usr/local/sbin/betterframe-rauc-mark-good.sh install -d -m 755 /etc/tmpfiles.d diff --git a/deploy/systemd/betterframe-rauc-mark-good.service b/deploy/systemd/betterframe-rauc-mark-good.service index 4f8d2a90..2b3607c5 100644 --- a/deploy/systemd/betterframe-rauc-mark-good.service +++ b/deploy/systemd/betterframe-rauc-mark-good.service @@ -10,6 +10,9 @@ ConditionPathExists=/usr/bin/rauc Type=oneshot ExecStart=/usr/local/sbin/betterframe-rauc-mark-good.sh RemainAfterExit=yes +Restart=on-failure +RestartSec=10s +TimeoutStartSec=360s [Install] WantedBy=multi-user.target diff --git a/deploy/systemd/betterframe-rauc-mark-good.sh b/deploy/systemd/betterframe-rauc-mark-good.sh index b0fbff89..d00ee9a4 100644 --- a/deploy/systemd/betterframe-rauc-mark-good.sh +++ b/deploy/systemd/betterframe-rauc-mark-good.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -MARKER="/run/betterframe/kiosk-healthy" -CONFIRMED_MARKER="/run/betterframe/rauc-confirmed" +MARKER="${BF_RAUC_HEALTH_MARKER:-/run/betterframe/kiosk-healthy}" +CONFIRMED_MARKER="${BF_RAUC_CONFIRMED_MARKER:-/run/betterframe/rauc-confirmed}" TIMEOUT="${BF_RAUC_MARK_GOOD_TIMEOUT:-300}" if ! command -v rauc >/dev/null 2>&1; then diff --git a/deploy/systemd/betterframe-rauc-state.sh b/deploy/systemd/betterframe-rauc-state.sh new file mode 100644 index 00000000..340e36f7 --- /dev/null +++ b/deploy/systemd/betterframe-rauc-state.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# RAUC metadata must survive replacement of either root slot. BF_DATA is +# root-owned; only its kiosk/recordings children are writable by the kiosk. +set -euo pipefail +legacy="${BF_RAUC_LEGACY_DIR:-/var/lib/rauc}" +shared="${BF_RAUC_SHARED_DIR:-/var/lib/betterframe/rauc}" +if [ ! -e "$shared/.migrated" ] || [ "${1:-}" = --refresh-legacy ]; then + install -d -m 700 "$shared" + if [ -d "$legacy" ]; then + if [ "${1:-}" = --refresh-legacy ]; then + cp -a "$legacy/." "$shared/" + else + cp -a -n "$legacy/." "$shared/" + fi + fi + touch "$shared/.migrated" + sync -f "$shared" +fi diff --git a/deploy/systemd/rauc.service b/deploy/systemd/rauc.service index 85d405a1..7ea960be 100644 --- a/deploy/systemd/rauc.service +++ b/deploy/systemd/rauc.service @@ -2,10 +2,12 @@ Description=RAUC Update Service Documentation=https://rauc.readthedocs.io After=dbus.service +RequiresMountsFor=/var/lib/betterframe [Service] Type=dbus BusName=de.pengutronix.rauc +ExecStartPre=/usr/local/sbin/betterframe-rauc-state.sh ExecStart=/usr/bin/rauc service [Install] diff --git a/deploy/x86-image/build-image.sh b/deploy/x86-image/build-image.sh index b52fad89..bc10e68d 100755 --- a/deploy/x86-image/build-image.sh +++ b/deploy/x86-image/build-image.sh @@ -101,6 +101,7 @@ cp "${REPO_ROOT}/deploy/mediamtx.version" "${WORK}/root/tmp/bf-files/" cp "${REPO_ROOT}/deploy/systemd/betterframe-firmware-rollback.sh" "${WORK}/root/tmp/bf-files/" cp "${REPO_ROOT}/deploy/systemd/betterframe-rauc-mark-good.service" "${WORK}/root/tmp/bf-files/" cp "${REPO_ROOT}/deploy/systemd/betterframe-rauc-mark-good.sh" "${WORK}/root/tmp/bf-files/" +cp "${REPO_ROOT}/deploy/systemd/betterframe-rauc-state.sh" "${WORK}/root/tmp/bf-files/" cp "${REPO_ROOT}/deploy/systemd/betterframe-expand-data.service" "${WORK}/root/tmp/bf-files/" cp "${REPO_ROOT}/deploy/systemd/betterframe-expand-data.sh" "${WORK}/root/tmp/bf-files/" cp "${REPO_ROOT}/deploy/systemd/betterframe-apply-managed-config.sh" "${WORK}/root/tmp/bf-files/" @@ -189,6 +190,7 @@ install -m 644 /tmp/bf-files/cage.pam /etc/pam.d/cage install -m 755 /tmp/bf-files/betterframe-firmware-rollback.sh /usr/local/sbin/betterframe-firmware-rollback.sh install -m 644 /tmp/bf-files/betterframe-rauc-mark-good.service /etc/systemd/system/betterframe-rauc-mark-good.service install -m 755 /tmp/bf-files/betterframe-rauc-mark-good.sh /usr/local/sbin/betterframe-rauc-mark-good.sh +install -m 755 /tmp/bf-files/betterframe-rauc-state.sh /usr/local/sbin/betterframe-rauc-state.sh install -m 644 /tmp/bf-files/betterframe-expand-data.service /etc/systemd/system/betterframe-expand-data.service install -m 755 /tmp/bf-files/betterframe-expand-data.sh /usr/local/sbin/betterframe-expand-data.sh install -m 755 /tmp/bf-files/betterframe-apply-managed-config.sh /usr/local/sbin/betterframe-apply-managed-config.sh diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 9c608cf7..2cb329d2 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -1,3 +1,4 @@ +import { reconcileOsUpdateReport } from "../../shared/os-update-status.js"; /** * service-api-http — h3 listener for kiosk-facing REST API. * @@ -1732,7 +1733,10 @@ export function registerKioskRoutes( const kiosk = await requireKiosk(event, repo, auth); const body = validateBody(OsAppliedBody, await readBody(event)); - await repo.recordKioskOsUpdateAttempt(kiosk.id, body.version, body.error ?? null, body.error ? "failed" : "pending_reboot"); + const current = await repo.getKioskById(kiosk.id); + if (!current) throw createError({ statusCode: 404, statusMessage: "kiosk not found" }); + const report = reconcileOsUpdateReport(current, { version: body.version, error: body.error ?? null, state: body.error ? "failed" : "pending_reboot" }); + if (report) await repo.recordKioskOsUpdateAttempt(kiosk.id, report.version, report.error, report.state); await repo.insertEvent({ source_kiosk_id: kiosk.id, source_camera_id: null, @@ -1752,7 +1756,10 @@ export function registerKioskRoutes( app.post("/api/kiosk/os/status", async (event) => { const kiosk = await requireKiosk(event, repo, auth); const body = validateBody(OsStatusBody, await readBody(event)); - await repo.recordKioskOsUpdateAttempt(kiosk.id, body.version, body.error ?? null, body.state); + const current = await repo.getKioskById(kiosk.id); + if (!current) throw createError({ statusCode: 404, statusMessage: "kiosk not found" }); + const report = reconcileOsUpdateReport(current, { version: body.version, error: body.error ?? null, state: body.state }); + if (report) await repo.recordKioskOsUpdateAttempt(kiosk.id, report.version, report.error, report.state); await repo.insertEvent({ source_kiosk_id: kiosk.id, source_camera_id: null, diff --git a/server/src/shared/os-update-status.ts b/server/src/shared/os-update-status.ts new file mode 100644 index 00000000..09242810 --- /dev/null +++ b/server/src/shared/os-update-status.ts @@ -0,0 +1,32 @@ +import type { Kiosk } from "./types.js"; +import { isVersionUpgrade } from "./version.js"; + +export type OsUpdateReport = { version: string; state: Kiosk["os_update_state"]; error: string | null }; +type Previous = Pick; + +/** Legacy clients may confirm the running slot while another slot is awaiting reboot. + * That is not proof of rollback. Keep the target until a matching confirmation or + * an explicit rollback report arrives. */ +export function reconcileOsUpdateReport(previous: Previous, report: OsUpdateReport): OsUpdateReport | null { + const target = previous.os_update_last_attempt_version; + if (!target) return report; + const state = previous.os_update_state; + if (report.version !== target) { + if (report.state === "confirmed" && ["installed", "pending_reboot", "failed", "rolled_back"].includes(state)) { + if (isVersionUpgrade(report.version, target)) return report; + if (previous.os_update_last_error) return null; + return { + version: target, state, + error: `Running OS ${report.version} has not confirmed update ${target}. Awaiting the device's update result.`, + }; + } + // A delayed report for an older release must not erase a newer attempt/result. + if (isVersionUpgrade(target, report.version)) return null; + } else if (state === "confirmed" && report.state !== "confirmed" && report.state !== "rolled_back") { + // A late install/failure response cannot undo confirmation of this same release. + // Explicit rollback is different: a later boot may fall back after confirmation. + return null; + } + if (report.version === target && report.state === state && report.error === previous.os_update_last_error) return null; + return report; +} diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 140faabf..84038b60 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -5108,11 +5108,20 @@ export function KioskOsUpdatePanel(props: KioskOsUpdatePanelProps) { const matchingReleases = compatibility ? props.releases.filter((r) => !r.yanked_at && r.compatibility === compatibility) : []; + const statusLabels = { + installed: "Installed — awaiting reboot scheduling", + pending_reboot: "Awaiting reboot and boot confirmation", + confirmed: "Boot confirmed", + rolled_back: "Rolled back — update did not pass boot confirmation", + failed: "Update failed", + }; + const needsRetry = k.os_update_state === "failed" || k.os_update_state === "rolled_back"; return (

OS

Running: {current}
+
Status: {k.os_update_last_attempt_version ? statusLabels[k.os_update_state] : "No update attempt reported"}
Compatibility: {compatibility ? {compatibility} : "unknown, waiting for kiosk check-in"}
{k.os_update_last_attempt_version && (
@@ -5162,7 +5171,7 @@ export function KioskOsUpdatePanel(props: KioskOsUpdatePanelProps) { "hx-post": `/admin/kiosks/${String(k.id)}/os-update/push`, "hx-swap": "none", }} - >Push OS update now + >{needsRetry ? "Retry OS update now" : "Push OS update now"}
}
diff --git a/server/tests/offline-kiosk.test.ts b/server/tests/offline-kiosk.test.ts index 4fdc7985..6ae91e84 100644 --- a/server/tests/offline-kiosk.test.ts +++ b/server/tests/offline-kiosk.test.ts @@ -25,11 +25,16 @@ test("persistent kiosk data is mounted before services can use it", () => { } }); -test("unpaired kiosks check signed OS updates before starting pairing", () => { +test("unpaired kiosks render pairing and confirm boot before checking signed OS updates", () => { const kiosk = readFileSync(new URL("../../client/src/platform/linux/ui.rs", import.meta.url), "utf8"); const api = readFileSync(new URL("../src/plugins/service-api-http/index.ts", import.meta.url), "utf8"); const proxy = readFileSync(new URL("../../deploy/angie/betterframe.docker.conf", import.meta.url), "utf8"); - assert.ok(kiosk.indexOf("os_update::check_public(&server)") < kiosk.indexOf("server::initiate_pairing(&server)")); + const update = kiosk.indexOf("os_update::check_public(&server)"); + assert.ok(kiosk.indexOf("WorkerMsg::ShowPairingCode(session.code.clone())") < update); + assert.ok(kiosk.indexOf('server::ota_enabled("BF_ENABLE_OS_OTA") && os_update::boot_is_confirmed()') < update); + assert.ok(update < kiosk.indexOf("server::poll_claim_until_expiry")); + const pairingScreen = kiosk.slice(kiosk.indexOf("fn show_pairing_code("), kiosk.indexOf("fn show_pairing_progress(")); + assert.match(pairingScreen, /mark_kiosk_healthy\(\)/); assert.match(api, /\/api\/os\/public\/check/); assert.match(api, /\/api\/os\/public\/download\/:id/); assert.match(proxy, /\^\/api\/\(firmware\|os\)\/public\//); diff --git a/server/tests/os-update-status.test.ts b/server/tests/os-update-status.test.ts new file mode 100644 index 00000000..2c1589da --- /dev/null +++ b/server/tests/os-update-status.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { reconcileOsUpdateReport } from "../src/shared/os-update-status.js"; +import { KioskOsUpdatePanel } from "../src/web-templates/admin-pages.js"; +import type { Kiosk } from "../src/shared/types.js"; + +const previous = (state: Kiosk["os_update_state"], version = "1.0.0", error: string | null = null) => ({ + os_update_last_attempt_version: version, os_update_state: state, os_update_last_error: error, +}); + +test("old slot confirmation preserves pending target without inventing a rollback", () => { + const report = reconcileOsUpdateReport(previous("pending_reboot"), { version: "0.0.318", state: "confirmed", error: null }); + assert.equal(report?.version, "1.0.0"); + assert.equal(report?.state, "pending_reboot"); + assert.match(report!.error!, /Running OS 0.0.318 has not confirmed update 1.0.0/); + assert.equal(reconcileOsUpdateReport(previous("pending_reboot", "1.0.0", report!.error), { version: "0.0.318", state: "confirmed", error: null }), null); +}); + +test("explicit rollback keeps attempted target and recovery diagnostic until confirmed", () => { + const rollback = { version: "1.0.0", state: "rolled_back" as const, error: "Boot returned to OS 0.0.318; target 1.0.0 was not confirmed" }; + assert.deepEqual(reconcileOsUpdateReport(previous("pending_reboot"), rollback), rollback); + assert.equal(reconcileOsUpdateReport(previous("rolled_back", rollback.version, rollback.error), { version: "0.0.318", state: "confirmed", error: null }), null); + const confirmed = { version: "1.0.0", state: "confirmed" as const, error: null }; + assert.deepEqual(reconcileOsUpdateReport(previous("rolled_back", rollback.version, rollback.error), confirmed), confirmed); +}); + +test("delayed install and failure reports cannot undo a successful confirmation", () => { + for (const version of ["0.0.318", "1.0.0"]) { + for (const state of ["failed", "pending_reboot", "installed"] as const) { + assert.equal(reconcileOsUpdateReport(previous("confirmed"), { version, state, error: "late report" }), null); + } + } + const next = { version: "1.0.1", state: "pending_reboot" as const, error: null }; + assert.deepEqual(reconcileOsUpdateReport(previous("confirmed"), next), next); +}); + +test("admin OS panel exposes pending, rollback and failure with recovery controls", () => { + for (const state of ["pending_reboot", "rolled_back", "failed"] as const) { + const kiosk = { id: "kiosk", os_version: "0.0.318", ...previous(state), os_update_last_error: "Install stage: reboot not confirmed", logging_json: "{}" } as Kiosk; + const html = String(KioskOsUpdatePanel({ kiosk, releases: [] })); + assert.match(html, /Last attempt:.*1.0.0/); + assert.match(html, /Install stage: reboot not confirmed/); + assert.match(html, state === "pending_reboot" ? /Awaiting reboot and boot confirmation/ : /Retry OS update now/); + } +}); + +test("both OS reporting endpoints reconcile against stored kiosk state", async () => { + const { H3 } = await import("h3"); + const { registerKioskRoutes } = await import("../src/plugins/service-api-http/index.js"); + const writes: unknown[][] = []; + const app = new H3(); + app.use((event) => { event.context.verifiedKiosk = { id: "kiosk" }; }); + registerKioskRoutes(app, { + getKioskById: async () => ({ id: "kiosk", ...previous("confirmed") }), + recordKioskOsUpdateAttempt: async (...args: unknown[]) => { writes.push(args); }, + insertEvent: async () => {}, + } as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, ""); + for (const path of ["applied", "status"]) { + const response = await app.request(`http://bf.test/api/kiosk/os/${path}`, { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ version: "0.0.318", error: "delayed install failure", ...(path === "status" ? { state: "failed" } : {}) }), + }); + assert.equal(response.status, 200); + } + assert.deepEqual(writes, []); +}); + + +test("a later boot rollback supersedes confirmation of the same target", () => { + const rollback = { version: "1.0.0", state: "rolled_back" as const, error: "Later boot returned to running OS 0.0.318 instead of confirmed target 1.0.0" }; + assert.deepEqual(reconcileOsUpdateReport(previous("confirmed"), rollback), rollback); + // A delayed rollback for an older release must still leave a newer result alone. + assert.equal(reconcileOsUpdateReport(previous("confirmed", "1.0.1"), rollback), null); +}); From 51fef7b701c3146b5c51d91a8e8f3faf7da459a6 Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Tue, 15 Sep 2026 06:47:14 +0000 Subject: [PATCH 2/3] Fix Android SDK setup for stable release builds --- .github/workflows/android-release.yml | 2 ++ .github/workflows/android.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index ad5f0258..a99acb05 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -76,6 +76,8 @@ jobs: distribution: temurin java-version: '17' - uses: android-actions/setup-android@v3 + with: + packages: platform-tools - uses: dtolnay/rust-toolchain@stable with: targets: aarch64-linux-android,x86_64-linux-android diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 7d35b1e9..029aff35 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -22,6 +22,8 @@ jobs: distribution: temurin java-version: '17' - uses: android-actions/setup-android@v3 + with: + packages: platform-tools - uses: dtolnay/rust-toolchain@stable with: targets: aarch64-linux-android,x86_64-linux-android From 61abe693905e96b346e57a1a8f1818dcc339605f Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:02:00 +0000 Subject: [PATCH 3/3] Address OS update spawn failure and reboot test review --- client/src/platform/linux/os_update.rs | 67 ++++++++++++++++++---- deploy/rauc/tests/test_update_lifecycle.py | 5 +- 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/client/src/platform/linux/os_update.rs b/client/src/platform/linux/os_update.rs index 707a04ec..58b9d3f9 100644 --- a/client/src/platform/linux/os_update.rs +++ b/client/src/platform/linux/os_update.rs @@ -496,20 +496,12 @@ fn apply_inner( return Err("OS installer is busy or unavailable; installation deferred".into()); } save_stage(Stage::Installing, None)?; - let mut child = Command::new("rauc") + let mut command = Command::new("rauc"); + command .args(["install", bundle_path.to_str().unwrap_or("")]) .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| { - let _ = report_applied( - server, - key, - &info.version, - Some(&format!("rauc spawn: {e}")), - ); - format!("rauc spawn: {e}") - })?; + .stderr(Stdio::piped()); + let mut child = spawn_installer(&mut command, save_stage)?; let mut child_stdout = child .stdout .take() @@ -687,6 +679,57 @@ pub fn boot_is_confirmed() -> bool { || !std::path::Path::new("/etc/rauc/system.conf").exists() } +// Persist Installing before spawn to cover power loss during handoff, but +// undo that uncertainty when the OS proves no child process was created. +fn spawn_installer( + command: &mut Command, + persist: impl FnOnce(Stage, Option) -> Result<(), String>, +) -> Result { + command.spawn().map_err(|error| { + let message = format!("rauc spawn: {error}"); + match persist(Stage::Failed, Some(message.clone())) { + Ok(()) => message, + Err(persist_error) => { + format!("{message}; could not persist retryable state: {persist_error}") + } + } + }) +} + +#[cfg(test)] +mod spawn_tests { + use super::*; + + #[test] + fn missing_executable_restores_retryable_journal() { + let directory = std::env::temp_dir().join(format!("bf-os-spawn-{}", std::process::id())); + fs::create_dir_all(&directory).unwrap(); + let path = directory.join("journal.json"); + let mut journal = Journal { + version: "1.0.1".into(), + release_id: "release".into(), + boot_id: "boot".into(), + stage: Stage::Installing, + error: None, + }; + os_journal::write(&path, &journal).unwrap(); + let error = spawn_installer( + &mut Command::new(directory.join("missing-rauc")), + |stage, error| { + journal.stage = stage; + journal.error = error; + os_journal::write(&path, &journal) + }, + ) + .unwrap_err(); + let persisted = os_journal::read(&path).unwrap().unwrap(); + assert_eq!(persisted.stage, Stage::Failed); + assert!(!persisted.blocks_apply()); + assert_eq!(persisted.error.as_deref(), Some(error.as_str())); + fs::remove_dir_all(directory).unwrap(); + } +} + fn format_command_failure( command: &str, status: std::process::ExitStatus, diff --git a/deploy/rauc/tests/test_update_lifecycle.py b/deploy/rauc/tests/test_update_lifecycle.py index f83a122e..79e1cc28 100644 --- a/deploy/rauc/tests/test_update_lifecycle.py +++ b/deploy/rauc/tests/test_update_lifecycle.py @@ -21,7 +21,7 @@ def setUp(self): shutil.copyfile(DEPLOY / 'rauc/reboot-after-install.sh', self.root / 'guard.sh') self.env = dict(os.environ, PATH=f'{self.bin}:{os.environ["PATH"]}', BF_REBOOT_STATUS_FILE=str(self.status), BF_REBOOT_GRACE_SECONDS='0', - BF_REBOOT_WAIT_SECONDS='1', CALLS=str(self.log), + BF_REBOOT_WAIT_SECONDS='10', CALLS=str(self.log), COUNT=str(self.root / 'count'), MODE='success', BF_RAUC_SYSTEM_CONF=str(self.root / 'system.conf'), BF_RAUC_ACTIVATION_STATE=str(self.root / 'slot-state')) @@ -53,7 +53,7 @@ def script(self, path, body): def run_guard(self, mode='success', platform='x86'): self.env['MODE'] = mode return subprocess.run(['bash', str(self.root / 'guard.sh'), platform, 'rootfs.1', 's ":1.42"'], - env=self.env, capture_output=True, text=True, timeout=8) + env=self.env, capture_output=True, text=True, timeout=20) def test_x86_waits_for_completion_and_activation(self): result = self.run_guard() @@ -86,6 +86,7 @@ def test_unactivated_target_does_not_reboot(self): self.assertFalse(self.log.exists()) def test_busy_install_times_out_without_reboot(self): + self.env['BF_REBOOT_WAIT_SECONDS'] = '1' self.assertNotEqual(self.run_guard('timeout').returncode, 0) self.assertFalse(self.log.exists()) self.assertIn('deadline', self.status.read_text())