From 5ba7154f6fffc085dca9bbb547675576b61bafa6 Mon Sep 17 00:00:00 2001 From: David Runge Date: Wed, 20 Sep 2023 19:27:16 +0200 Subject: [PATCH 1/5] feat: Add D-Bus interface for interactive use Caterpillar is turned into a state machine driven application, that is designed to be running in the background. A D-Bus interface allows to interact with the application and to trigger certain actions such as: * searching for an update * installing (and optionally rebooting) or skipping the installation of an update These blocking actions, as well as the state handling are run in separate tokio tasks (threads). Various state related facilities are exposed as properties. More information on the interface can be found in the XML describing it. Further integration for D-Bus and systemd is added/updated to fully integrate also with scenarios in which caterpillar is started over D- Bus. Signed-off-by: David Runge --- .reuse/dep5 | 4 + Cargo.lock | 24 +- Cargo.toml | 3 + dist/caterpillar.toml | 7 - dist/config/caterpillar.toml | 19 + dist/dbus/de.sleepmap.Caterpillar.conf | 13 + dist/dbus/de.sleepmap.Caterpillar.service | 8 + dist/dbus/de.sleepmap.Caterpillar.xml | 97 +++ dist/{ => systemd}/caterpillar.service | 4 + src/config.rs | 6 +- src/dbus.rs | 764 ++++++++++++++++++ src/error.rs | 26 + src/main.rs | 280 +------ .../etc/systemd/system/caterpillar.service | 7 + .../de.sleepmap.Caterpillar.service | 8 + .../system.d/de.sleepmap.Caterpillar.conf | 13 + 16 files changed, 1022 insertions(+), 261 deletions(-) delete mode 100644 dist/caterpillar.toml create mode 100644 dist/config/caterpillar.toml create mode 100644 dist/dbus/de.sleepmap.Caterpillar.conf create mode 100644 dist/dbus/de.sleepmap.Caterpillar.service create mode 100644 dist/dbus/de.sleepmap.Caterpillar.xml rename dist/{ => systemd}/caterpillar.service (70%) create mode 100644 src/dbus.rs create mode 100644 tests/mkosi/ab_image/mkosi.extra/usr/share/dbus-1/system-services/de.sleepmap.Caterpillar.service create mode 100644 tests/mkosi/ab_image/mkosi.extra/usr/share/dbus-1/system.d/de.sleepmap.Caterpillar.conf diff --git a/.reuse/dep5 b/.reuse/dep5 index ef83460..95e59a4 100644 --- a/.reuse/dep5 +++ b/.reuse/dep5 @@ -10,3 +10,7 @@ License: CC0-1.0 Files: docs/overview.dot docs/overview.svg Copyright: 2023 David Runge License: CC-BY-SA-4.0 + +Files: dist/dbus/de.sleepmap.Caterpillar.conf dist/dbus/de.sleepmap.Caterpillar.xml tests/mkosi/ab_image/mkosi.extra/usr/share/dbus-1/system.d/de.sleepmap.Caterpillar.conf +Copyright: 2023 David Runge +License: LGPL-3.0-or-later diff --git a/Cargo.lock b/Cargo.lock index 355d796..a81f7b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,7 +80,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c48ccdbf6ca6b121e0f586cbc0e73ae440e56c67c30fa0873b4e110d9c26d2b" dependencies = [ - "event-listener", + "event-listener 2.5.3", "futures-core", ] @@ -91,7 +91,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" dependencies = [ "concurrent-queue", - "event-listener", + "event-listener 2.5.3", "futures-core", ] @@ -151,7 +151,7 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" dependencies = [ - "event-listener", + "event-listener 2.5.3", ] [[package]] @@ -165,7 +165,7 @@ dependencies = [ "autocfg", "blocking", "cfg-if", - "event-listener", + "event-listener 2.5.3", "futures-lite", "rustix 0.37.23", "signal-hook", @@ -365,12 +365,14 @@ dependencies = [ "config", "dbus-launch", "dbus-udisks2", + "event-listener 3.0.0", "fslock", "futures", "once_cell", "regex", "rstest", "semver", + "serde", "serial_test", "strum", "strum_macros", @@ -384,6 +386,7 @@ dependencies = [ "version-compare", "which", "zbus", + "zbus_macros", "zvariant", ] @@ -610,6 +613,17 @@ version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" +[[package]] +name = "event-listener" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29e56284f00d94c1bc7fd3c77027b4623c88c1f53d8d2394c6199f2921dea325" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "1.9.0" @@ -2122,7 +2136,7 @@ dependencies = [ "byteorder", "derivative", "enumflags2", - "event-listener", + "event-listener 2.5.3", "futures-core", "futures-sink", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index 3b4f6ca..5c264e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,16 +26,19 @@ codegen-units = 1 async-std = {version = "1.12.0", features = ["attributes", "tokio1"]} config = "0.13.3" dbus-udisks2 = {version = "0.3.0", features = ["futures"]} +event-listener = "3.0.0" futures = "0.3.28" once_cell = "1.17.1" regex = "1.8.1" semver = "1.0.17" +serde = "1.0.188" strum = {version = "0.24.1", features = ["derive"]} strum_macros = "0.24.3" thiserror = "1.0.47" tokio = {version = "1.28.0", features = ["rt-multi-thread", "macros"]} version-compare = "0.1.1" zbus = {version = "3.12.0", default-features = false, features = ["tokio"]} +zbus_macros = "3.14.1" zvariant = "3.12.0" [dev-dependencies] diff --git a/dist/caterpillar.toml b/dist/caterpillar.toml deleted file mode 100644 index e963504..0000000 --- a/dist/caterpillar.toml +++ /dev/null @@ -1,7 +0,0 @@ -# SPDX-FileCopyrightText: 2023 David Runge -# SPDX-License-Identifier: LGPL-3.0-or-later - -bundle_extension = "raucb" -device_regex = "^/org/freedesktop/UDisks2/block_devices/sd[a-z]{1}[1-9]{1}[0-9]*?$" -override_dir = "override" -reboot = true diff --git a/dist/config/caterpillar.toml b/dist/config/caterpillar.toml new file mode 100644 index 0000000..bdfceec --- /dev/null +++ b/dist/config/caterpillar.toml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2023 David Runge +# SPDX-License-Identifier: LGPL-3.0-or-later + +# This configuration file documents the built-in defaults for caterpillar + +# Run non-interactively on first start. +# This automatically searches for an update, installs a matching update if found and reboots. +autorun = true + +# The file extension to search for at the top-level or in an override_dir on a mounted filesystem. +bundle_extension = "raucb" + +# The regular expression used to match for block devices discovered by udisks2 over D-Bus. +device_regex = "^/org/freedesktop/UDisks2/block_devices/sd[a-z]{1}[1-9]{1}[0-9]*?$" + +# The name of a directory in which override updates are searched for. +# Valid updates in this directory have precedence over those found in the top-level filesystem. +# This is useful for downgrade scenarios. +override_dir = "override" diff --git a/dist/dbus/de.sleepmap.Caterpillar.conf b/dist/dbus/de.sleepmap.Caterpillar.conf new file mode 100644 index 0000000..4812aea --- /dev/null +++ b/dist/dbus/de.sleepmap.Caterpillar.conf @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/dist/dbus/de.sleepmap.Caterpillar.service b/dist/dbus/de.sleepmap.Caterpillar.service new file mode 100644 index 0000000..a961503 --- /dev/null +++ b/dist/dbus/de.sleepmap.Caterpillar.service @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2023 David Runge +# SPDX-License-Identifier: LGPL-3.0-or-later + +[D-BUS Service] +Name=de.sleepmap.Caterpillar +Exec=/usr/bin/caterpillar +User=root +SystemdService=dbus-de.sleepmap.Caterpillar.service diff --git a/dist/dbus/de.sleepmap.Caterpillar.xml b/dist/dbus/de.sleepmap.Caterpillar.xml new file mode 100644 index 0000000..7df118a --- /dev/null +++ b/dist/dbus/de.sleepmap.Caterpillar.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dist/caterpillar.service b/dist/systemd/caterpillar.service similarity index 70% rename from dist/caterpillar.service rename to dist/systemd/caterpillar.service index 136f20d..54764aa 100644 --- a/dist/caterpillar.service +++ b/dist/systemd/caterpillar.service @@ -4,10 +4,14 @@ [Unit] ConditionPathExists=/usr/share/dbus-1/system-services/de.pengutronix.rauc.service ConditionPathExists=/usr/share/dbus-1/system-services/org.freedesktop.UDisks2.service +ConditionPathExists=/usr/share/dbus-1/system-services/org.freedesktop.login1.service Description=Search and install system updates [Service] +BusName=de.sleepmap.Caterpillar ExecStart=/usr/bin/caterpillar +Type=dbus [Install] +Alias=dbus-de.sleepmap.Caterpillar.service WantedBy=multi-user.target diff --git a/src/config.rs b/src/config.rs index 6989b9b..19e294d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,13 +4,15 @@ use config::{Config, ConfigError, File}; pub const DEVICE_REGEX: &str = "^/org/freedesktop/UDisks2/block_devices/sd[a-z]{1}[1-9]{1}[0-9]*?$"; +/// Read the configuration for the application +/// +/// This uses built-in defaults, which can be overridden with an optional configuration file found in /etc/caterpillar/caterpillar.toml pub async fn read_config() -> Result { Config::builder() - // by default we want to match any block device + .set_default("autorun", true)? .set_default("bundle_extension", "raucb")? .set_default("device_regex", DEVICE_REGEX)? .set_default("override_dir", "override")? - .set_default("reboot", true)? .add_source(File::with_name("/etc/caterpillar/caterpillar").required(false)) .add_source(config::Environment::with_prefix("CATERPILLAR")) .build() diff --git a/src/dbus.rs b/src/dbus.rs new file mode 100644 index 0000000..f7e6c16 --- /dev/null +++ b/src/dbus.rs @@ -0,0 +1,764 @@ +// SPDX-FileCopyrightText: 2023 David Runge +// SPDX-License-Identifier: Apache-2.0 OR MIT +use async_std::fs::rename; +use async_std::sync::RwLock; +use config::Config; +use event_listener::Event; +use semver::Version; +use serde::Deserialize; +use serde::Serialize; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::spawn; +use tokio::sync::mpsc::channel; +use tokio::sync::mpsc::Receiver; +use tokio::sync::mpsc::Sender; +use tokio::task::JoinHandle; +use tokio::time::sleep; +use tokio::time::Duration; +use zbus::names::BusName; +use zbus::names::InterfaceName; +use zbus::Connection; +use zbus::SignalContext; +use zbus_macros::dbus_interface; +use zvariant::ObjectPath; +use zvariant::Type; + +use crate::config::read_config; +use crate::device::Device; +use crate::device::UdisksInfo; +use crate::error::Error; +use crate::proxy::login1::ManagerProxy; +use crate::rauc::RaucInfo; +use crate::rauc::UpdateBundle; + +/// State of the application +#[derive(Clone, Debug, strum::Display, strum::EnumString, PartialEq)] +#[non_exhaustive] +pub enum State { + #[strum(to_string = "done")] + Done(bool, usize), + #[strum(to_string = "idle")] + Idle(bool, usize), + #[strum(to_string = "init")] + Init, + #[strum(to_string = "mounted")] + Mounted(bool, usize), + #[strum(to_string = "mounting")] + Mounting(bool, usize), + #[strum(to_string = "noupdatefound")] + NoUpdateFound(bool, usize), + #[strum(to_string = "searching")] + Searching(bool, usize), + #[strum(to_string = "skip")] + Skip(bool, usize), + #[strum(to_string = "unmounted")] + Unmounted(bool, usize, bool), + #[strum(to_string = "unmounting")] + Unmounting(bool, usize, bool), + #[strum(to_string = "updated")] + Updated(bool, usize, bool), + #[strum(to_string = "updatefound")] + UpdateFound(bool, usize), + #[strum(to_string = "updating")] + Updating(bool, usize), +} + +impl State { + /// Return whether the system has been updated successfully + /// + /// Since users may choose not to reboot right after update, this indicator helps in distinguishing whether to allow another update attempt. + pub fn get_updated(&self) -> bool { + match self { + State::Init => false, + State::Done(updated, _) + | State::UpdateFound(updated, _) + | State::Idle(updated, _) + | State::Mounting(updated, _) + | State::Mounted(updated, _) + | State::NoUpdateFound(updated, _) + | State::Searching(updated, _) + | State::Skip(updated, _) + | State::Unmounting(updated, _, _) + | State::Unmounted(updated, _, _) + | State::Updating(updated, _) + | State::Updated(updated, _, _) => updated.to_owned(), + } + } + + /// Return the iteration the program is currently in + /// + /// An iteration is defined by how often [`State::Unmounted`] has been reached + pub fn get_iteration(&self) -> usize { + match self { + State::Init => 0, + State::Done(_, iteration) + | State::UpdateFound(_, iteration) + | State::Idle(_, iteration) + | State::Mounting(_, iteration) + | State::Mounted(_, iteration) + | State::NoUpdateFound(_, iteration) + | State::Searching(_, iteration) + | State::Skip(_, iteration) + | State::Unmounting(_, iteration, _) + | State::Unmounted(_, iteration, _) + | State::Updating(_, iteration) + | State::Updated(_, iteration, _) => iteration.to_owned(), + } + } + + /// Return whether the system is marked for reboot + /// + /// A system is marked for reboot, if reboot has been selected as action after successful update + pub fn get_marked_for_reboot(&self) -> bool { + match self { + State::Init + | State::Done(_, _) + | State::UpdateFound(_, _) + | State::Idle(_, _) + | State::Mounting(_, _) + | State::Mounted(_, _) + | State::NoUpdateFound(_, _) + | State::Searching(_, _) + | State::Updating(_, _) + | State::Skip(_, _) => false, + State::Unmounting(_, _, reboot) + | State::Unmounted(_, _, reboot) + | State::Updated(_, _, reboot) => reboot.to_owned(), + } + } +} + +/// An Update as it is presented over D-BUS +/// +/// An update is represented by the (file) name, current (old) version of the system, the (new) version of the update +/// and whether the update is forced. +#[derive(Debug, Deserialize, PartialEq, Serialize, Type)] +struct Update { + name: String, + old_version: String, + new_version: String, + force: bool, +} + +impl Update { + /// Create an Update from an UpdateBundle and the current system version + pub fn from_bundle(bundle: &UpdateBundle, current_version: &Version) -> Self { + Self { + name: bundle.path(), + old_version: current_version.to_string(), + new_version: bundle.version().to_string(), + force: bundle.is_override(), + } + } +} + +/// The state of the application +pub struct StateHandle { + state: Arc>, + done: Arc, + sender: Option>, + thread: Option>>, +} + +impl StateHandle { + pub fn new(done: Event) -> Self { + Self { + state: Arc::new(RwLock::new(State::Init)), + done: Arc::new(done), + sender: None, + thread: None, + } + } + + /// Clone the state Sender + pub async fn sender_clone(&self) -> Result, Error> { + if let Some(sender) = self.sender.as_ref() { + Ok(sender.clone()) + } else { + Err(Error::Default("Unable to clone state Sender.".to_string())) + } + } + + pub async fn read_state(&self) -> State { + self.state.read_arc().await.clone() + } +} + +/// The main application and D-Bus interface +/// +/// The struct unifies the configuration, connection to other D-BUS proxies, central state, found devices and updates. +pub struct Caterpillar { + config: Config, + devices: Arc>>, + updates: Arc>>, + state_handle: StateHandle, +} + +impl Caterpillar { + /// Create a new Caterpillar instance + pub async fn new(done: Event) -> Result { + println!("Initializing Caterpillar"); + let mut caterpillar = Self { + config: read_config().await?, + devices: Arc::new(RwLock::new(vec![])), + updates: Arc::new(RwLock::new(vec![])), + state_handle: StateHandle::new(done), + }; + caterpillar.init().await?; + Ok(caterpillar) + } + + /// Initialize the application's state handling + async fn init(&mut self) -> Result<(), Error> { + // state + let (sender, mut receiver): (Sender, Receiver) = channel(2); + let state_sender = sender.clone(); + let state_lock = self.state_handle.state.clone(); + let done_lock = self.state_handle.done.clone(); + + // devices and updates + let devices_lock = self.devices.clone(); + let updates_lock = self.updates.clone(); + + // config data + let autorun = self.config().get_bool("autorun")?; + + // test connections to other services + let connection = Connection::system().await?; + test_connections(&connection).await?; + + // start task that receives state changes, persists and acts on them + self.state_handle.sender = Some(sender); + self.state_handle.thread = Some(spawn(async move { + let mut exit = false; + state_sender.send(State::Idle(false, 0)).await?; + while !exit { + if let Ok(state) = receiver.try_recv() { + println!("Entering state: {}", &state); + // let previous_state = state_lock.read_arc().await; + { + // update the state + let mut state_write = state_lock.write_arc().await; + *state_write = state; + } + + // match against a clone of the state so we do not block + let state_read = state_lock.read_arc().await.clone(); + match state_read { + State::Init + | State::Mounting(_, _) + | State::Mounted(_, _) + | State::Searching(_, _) + | State::Updating(_, _) => {} + State::Done(_, _) => { + exit = true; + done_lock.notify(1); + } + State::UpdateFound(_, iteration) => { + let updates = updates_lock.read_arc().await; + let connection = Connection::system().await?; + let rauc_info = RaucInfo::new(&connection).await?; + + // signal that we have found an update + println!("Signal over D-Bus, that an update is found"); + Caterpillar::update_found( + &SignalContext::from_parts( + connection.to_owned(), + ObjectPath::from_str_unchecked("/de/sleepmap/Caterpillar"), + ), + vec![Update::from_bundle( + &updates[0], + rauc_info.version().unwrap_or(&Version::new(0, 0, 0)), + )], + ) + .await?; + + // if this is the first iteration (i.e. boot) and configured to do so, install update and reboot + if iteration == 1 && autorun { + println!("Running in non-interactive mode. Install..."); + connection + .call_method( + Some( + BusName::try_from("de.sleepmap.Caterpillar") + .map_err(|x| Error::Default(x.to_string()))?, + ), + ObjectPath::try_from("/de/sleepmap/Caterpillar") + .map_err(|x| Error::Default(x.to_string()))?, + Some( + InterfaceName::try_from("de.sleepmap.Caterpillar") + .map_err(|x| Error::Default(x.to_string()))?, + ), + "InstallUpdate", + &(true, true), + ) + .await?; + } + } + State::NoUpdateFound(updated, iteration) => { + state_sender + .send(State::Unmounting(updated, iteration, false)) + .await?; + } + State::Idle(updated, iteration) => { + { + // increment our iteration + let mut state_write = state_lock.write_arc().await; + *state_write = State::Idle(updated, iteration + 1); + } + } + State::Skip(updated, iteration) => { + state_sender + .send(State::Unmounting(updated, iteration, false)) + .await?; + } + State::Unmounting(updated, iteration, reboot) => { + let connection = Connection::system().await?; + let mut devices = devices_lock.write_arc().await; + for device in devices.iter_mut() { + if device.is_mounted() { + device.unmount_filesystem(&connection).await?; + } + } + state_sender + .send(State::Unmounted(updated, iteration, reboot)) + .await?; + } + State::Unmounted(updated, iteration, reboot) => { + // if this is the first iteration, successfully updated and configured to do so, reboot + if updated && ((iteration == 1 && autorun) || reboot) { + let connection = Connection::system().await?; + println!("Connecting to logind over dbus..."); + let login_proxy = ManagerProxy::new(&connection).await?; + println!("Rebooting..."); + login_proxy.reboot(false).await?; + // return to idle state if not updated or no reboot is wanted + } else { + state_sender.send(State::Idle(updated, iteration)).await?; + } + + // reset devices and updates lists + { + let mut devices_write = devices_lock.write_arc().await; + *devices_write = vec![]; + } + { + let mut updates_write = updates_lock.write_arc().await; + *updates_write = vec![]; + } + } + State::Updated(_, iteration, reboot) => { + // mark ourselves as updated + state_sender + .send(State::Unmounting(true, iteration, reboot)) + .await?; + } + } + } + sleep(Duration::from_millis(100)).await; + } + Ok(()) + })); + Ok(()) + } + + /// Return a reference to the application's configuration + pub fn config(&self) -> &Config { + &self.config + } + + /// Return a reference to the done Event of the application + pub fn done(&self) -> &Event { + &self.state_handle.done + } + + /// Return the optional UpdateBundle, that the application found + async fn get_update(&self) -> Option { + self.updates + .read() + .await + .iter() + .last() + .map(|bundle| bundle.to_owned()) + } +} + +#[dbus_interface(name = "de.sleepmap.Caterpillar")] +impl Caterpillar { + /// Trigger the search for an update + /// + /// It is advised to subscribe to the `UpdateFound` signal before calling this method. + pub async fn search_for_update(&self) -> zbus::fdo::Result<()> { + println!("Search for update..."); + let state = self.state_handle.read_state().await; + match state { + State::Idle(updated, iteration) if !updated => { + let state_sender = self + .state_handle + .sender_clone() + .await + .map_err(|x| zbus::fdo::Error::Failed(x.to_string()))?; + let devices_lock = self.devices.clone(); + let (device_regex, bundle_extension, override_dir) = ( + self.config + .get_string("device_regex") + .map_err(|x| zbus::fdo::Error::Failed(x.to_string()))?, + self.config + .get_string("bundle_extension") + .map_err(|x| zbus::fdo::Error::Failed(x.to_string()))?, + self.config + .get_string("override_dir") + .map_err(|x| zbus::fdo::Error::Failed(x.to_string()))?, + ); + let updates_lock = self.updates.clone(); + let connection = Connection::system().await?; + + // run background task that mounts available devices and searches for compatible updates + spawn(async move { + state_sender + .send(State::Mounting(updated, iteration)) + .await + .map_err(|x| zbus::fdo::Error::Failed(x.to_string()))?; + let mut devices = devices_lock.write_arc().await; + // setup the devices (mounts) + *devices = mount_and_search_devices( + &connection, + &device_regex, + &bundle_extension, + &override_dir, + ) + .await + .map_err(|x| zbus::fdo::Error::Failed(x.to_string()))?; + + state_sender + .send(State::Mounted(updated, iteration)) + .await + .map_err(|x| zbus::fdo::Error::Failed(x.to_string()))?; + + let mut updates = updates_lock.write_arc().await; + let rauc_info = RaucInfo::new(&connection) + .await + .map_err(|x| zbus::fdo::Error::Failed(x.to_string()))?; + state_sender + .send(State::Searching(updated, iteration)) + .await + .map_err(|x| zbus::fdo::Error::Failed(x.to_string()))?; + // search for a compatible update bundle + match get_update_bundle(&connection, &rauc_info, &devices) + .await + .map_err(|x| zbus::fdo::Error::Failed(x.to_string()))? + { + Some(bundle) => { + println!( + "Found {}update {}", + if bundle.is_override() { + "override " + } else { + " " + }, + bundle.path() + ); + updates.push(bundle); + state_sender + .send(State::UpdateFound(updated, iteration)) + .await + .map_err(|x| zbus::fdo::Error::Failed(x.to_string()))?; + } + None => state_sender + .send(State::NoUpdateFound(updated, iteration)) + .await + .map_err(|x| zbus::fdo::Error::Failed(x.to_string()))?, + } + Ok::<(), zbus::fdo::Error>(()) + }); + Ok(()) + } + _ => Err(zbus::fdo::Error::AccessDenied(format!( + "Already in state {}", + state + ))), + } + } + + /// Trigger the installation of an update + /// + /// The parameters to this method provide information on whether to update (b) and whether to reboot afterwards (b) + async fn install_update(&self, update: bool, reboot: bool) -> zbus::fdo::Result<()> { + let state = self.state_handle.read_state().await; + match state { + State::UpdateFound(updated, iteration) if !updated && update => { + let state_sender = self + .state_handle + .sender_clone() + .await + .map_err(|x| zbus::fdo::Error::Failed(x.to_string()))?; + if let Some(bundle) = self.get_update().await { + spawn(async move { + println!( + "Install update {} and {}reboot", + &bundle, + if reboot { "" } else { "do not " } + ); + state_sender + .send(State::Updating(updated, iteration)) + .await + .map_err(|x| zbus::fdo::Error::Failed(x.to_string()))?; + + match bundle + .install(&Connection::system().await?) + .await + .map_err(|x| zbus::fdo::Error::Failed(x.to_string())) + { + Ok(()) => { + if bundle.is_override() { + println!("Disabling override bundle {}", bundle.path()); + if let Err(error) = rename( + bundle.path(), + format!("{}.installed", bundle.path()), + ) + .await + .map_err(|x| zbus::fdo::Error::Failed(x.to_string())) + { + eprintln!("{}", error); + return Err(error); + } + } + state_sender + .send(State::Updated(updated, iteration, reboot)) + .await + .map_err(|x| zbus::fdo::Error::Failed(x.to_string()))?; + } + Err(error) => { + eprintln!("{}", error); + return Err(error); + } + } + Ok(()) + }); + } else { + return Err(zbus::fdo::Error::Failed(format!( + "{}", + Error::NoUpdateBundle + ))); + } + } + State::NoUpdateFound(updated, iteration) | State::UpdateFound(updated, iteration) + if !update => + { + let state_sender = self + .state_handle + .sender_clone() + .await + .map_err(|x| zbus::fdo::Error::Failed(x.to_string()))?; + state_sender + .send(State::Skip(updated, iteration)) + .await + .map_err(|x| zbus::fdo::Error::Failed(x.to_string()))?; + } + _ => { + if state.get_updated() { + return Err(zbus::fdo::Error::Failed(format!( + "{}", + Error::WrongState( + "System is updated already, waiting for reboot".to_string() + ) + ))); + } else { + return Err(zbus::fdo::Error::Failed(format!( + "{}", + Error::WrongState(format!("{}", state)) + ))); + } + } + } + Ok(()) + } + + /// The internal state of Caterpillar + /// + /// One of + /// - "done" + /// - "idle" + /// - "init" + /// - "mounted" + /// - "mounting" + /// - "noupdatefound" + /// - "searching" + /// - "skip" + /// - "unmounted" + /// - "unmounting" + /// - "updated" + /// - "updatefound" + /// - "updating" + #[dbus_interface(property)] + async fn state(&self) -> String { + format!("{}", self.state_handle.read_state().await) + } + + /// Whether the system has been successfully updated + #[dbus_interface(property)] + async fn updated(&self) -> bool { + self.state_handle.read_state().await.get_updated() + } + + /// Whether the system has been marked for reboot when requesting the installation of an update + #[dbus_interface(property)] + async fn marked_for_reboot(&self) -> bool { + self.state_handle.read_state().await.get_marked_for_reboot() + } + + /// A signal, broadcasting information on found updates + /// + /// The update is returned in an array of length one. + /// The update information consists of the absolute filename (s), + /// the current version of the system (s), + /// the new version (s) + /// and whether the update is an override (b) + #[dbus_interface(signal)] + async fn update_found(ctxt: &SignalContext<'_>, update: Vec) -> zbus::Result<()>; +} + +/// Test connections to UdisksInfo, RaucInfo and ManagerProxy instances in a Result +async fn test_connections(connection: &Connection) -> Result<(), Error> { + println!("Connecting to logind over dbus..."); + if let Err(error) = ManagerProxy::new(connection).await { + return Err(Error::Dbus(error)); + }; + + println!("Connecting to Udisks2 over dbus..."); + match UdisksInfo::new(connection).await { + Ok(proxy) => { + println!("Communicating with Udisks2 {}", proxy.version()); + } + Err(error) => return Err(error), + } + + println!("Connecting to RAUC over dbus..."); + match RaucInfo::new(connection).await { + Ok(proxy) => { + println!("{}", proxy); + println!("RAUC slot info:"); + for slot in proxy.slots() { + println!("{}", slot); + } + } + Err(error) => return Err(error), + } + + Ok(()) +} + +/// Return a list of matching and mounted Device instances that have been searched for UpdateBundles in a Result +async fn mount_and_search_devices( + connection: &Connection, + device_regex: &str, + bundle_extension: &str, + override_dir: &str, +) -> Result, Error> { + println!("Searching for compatible block devices..."); + let mut devices = UdisksInfo::get_block_devices(connection, device_regex).await?; + + for device in &mut devices[..] { + match device.mount_filesystem(connection).await { + Ok(_path) => { + // gather PathBufs of update bundles + if let Err(error) = device.find_bundles(bundle_extension).await { + eprintln!("{}", error) + } + + // gather PathBufs of override update bundles + if let Err(error) = device + .find_override_bundles(bundle_extension, Path::new(&override_dir)) + .await + { + eprintln!("{}", error) + } + } + Err(error) => eprintln!("{}", error), + } + } + Ok(devices) +} + +/// Get an optional UpdateBundle to update to in a Result +async fn get_update_bundle( + connection: &Connection, + rauc_info: &RaucInfo, + devices: &[Device], +) -> Result, Error> { + println!("Search for compatible RAUC update bundle..."); + // get paths to all override bundles + let override_bundle_paths: Vec = devices + .iter() + .filter_map(|x| x.override_bundles()) + .flatten() + .collect(); + + match override_bundle_paths.len() { + 0 => {} + // install override bundle + 1 => match UpdateBundle::new(&override_bundle_paths[0], true, connection).await { + Ok(bundle) => { + if bundle.compatible() == rauc_info.compatible() { + return Ok(Some(bundle)); + } else { + eprintln!( + "Update bundle {} is not compatible with this system!", + bundle.path() + ) + } + } + Err(error) => eprintln!("{}", error), + }, + // error if there is more than one override bundle + _ => return Err(Error::TooManyOverrides(override_bundle_paths)), + } + + // get paths to all top-level bundles + let bundle_paths: Vec = devices + .iter() + .filter_map(|x| x.bundles()) + .flatten() + .collect(); + + if !bundle_paths.is_empty() { + let mut bundles = vec![]; + for path in bundle_paths { + match UpdateBundle::new(&path, false, connection).await { + Ok(bundle) => { + println!("Found update bundle: {}", bundle.path()); + // add bundle only if it is compatible and if its version is higher than the current + if bundle.compatible() == rauc_info.compatible() { + if rauc_info.version().is_none() + || rauc_info.version().is_some_and(|x| bundle.version().gt(x)) + { + println!( + "Adding update bundle {} to list of compatible bundles...", + bundle.path() + ); + bundles.push(bundle); + } else { + eprintln!("Update bundle {} is compatible, but its version ({}) is lower or equal to the current ({})", bundle.path(), bundle.version(), rauc_info.version_string()); + } + } else { + eprintln!("Update bundle {} is not compatible!", bundle.path()); + } + } + Err(error) => eprintln!("{}", error), + } + } + + if bundles.is_empty() { + Ok(None) + } else { + // sort by version + bundles.sort(); + bundles.reverse(); + println!("Selecting update bundle {}...", bundles[0].path()); + Ok(Some(bundles[0].clone())) + } + } else { + Ok(None) + } +} diff --git a/src/error.rs b/src/error.rs index 30f9bde..c4827c6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -7,6 +7,8 @@ use std::string::FromUtf8Error; use config::ConfigError; +use crate::dbus::State; + /// An error that could occur when caterpillar runs #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -32,6 +34,12 @@ pub enum Error { /// A problem with dbus #[error("A problem occurred while communicating over dbus: {0}")] Dbus(zbus::Error), + /// A problem with internal dbus + #[error("A problem occurred while communicating over dbus internally: {0}")] + DbusInternal(zbus::fdo::Error), + /// A problem with communicating state between threads + #[error("An internal error occurred communicating between threads over channels: {0}")] + StateChannel(tokio::sync::mpsc::error::SendError), /// A file issue #[error("An error occurred reading or writing a file: {0}")] File(io::Error), @@ -64,6 +72,24 @@ pub enum Error { /// Installing an update bundle failed #[error("Update failed: {0}")] UpdateFailed(String), + #[error("Caterpillar is in wrong state: {0}")] + WrongState(String), + #[error("Failed initializing: {0}")] + Init(String), + #[error("An error occurred: {0}")] + Default(String), +} + +impl From> for Error { + fn from(value: tokio::sync::mpsc::error::SendError) -> Self { + Error::StateChannel(value) + } +} + +impl From for Error { + fn from(value: zbus::fdo::Error) -> Self { + Error::DbusInternal(value) + } } impl From for Error { diff --git a/src/main.rs b/src/main.rs index 1d34559..6577bf0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,198 +1,21 @@ // SPDX-FileCopyrightText: 2023 David Runge // SPDX-License-Identifier: Apache-2.0 OR MIT -use device::Device; -use device::UdisksInfo; -use std::collections::HashMap; -use std::fs::rename; -use std::path::Path; -use std::path::PathBuf; -use zbus::Connection; +use event_listener::Event; +use zbus::names::BusName; +use zbus::names::InterfaceName; +use zbus::ConnectionBuilder; +use zvariant::ObjectPath; mod config; -use crate::config::read_config; - +mod dbus; mod device; - -mod rauc; -use rauc::RaucInfo; -use rauc::UpdateBundle; - mod error; -use error::Error; - mod macros; - mod proxy; -use crate::proxy::login1::ManagerProxy; - -/// State of the updater -#[derive(Debug, Clone, Eq, PartialEq)] -#[non_exhaustive] -pub enum State { - Mounting, - Searching, - Updating, - Updated, -} - -/// Return UdisksInfo, RaucInfo and ManagerProxy instances in a Result -async fn get_connections( - connection: &Connection, -) -> Result<(UdisksInfo, RaucInfo, ManagerProxy), Error> { - println!("Connecting to logind over dbus..."); - let login_proxy = match ManagerProxy::new(connection).await { - Ok(proxy) => proxy, - Err(error) => return Err(Error::Dbus(error)), - }; - - println!("Connecting to Udisks2 over dbus..."); - let udisks_proxy = match UdisksInfo::new(connection).await { - Ok(proxy) => { - println!("Communicating with Udisks2 {}", proxy.version()); - proxy - } - Err(error) => return Err(error), - }; - - println!("Connecting to RAUC over dbus..."); - let rauc_proxy = match RaucInfo::new(connection).await { - Ok(proxy) => { - println!("{}", proxy); - println!("RAUC slot info:"); - for slot in proxy.slots() { - println!("{}", slot); - } - proxy - } - Err(error) => return Err(error), - }; - - Ok((udisks_proxy, rauc_proxy, login_proxy)) -} - -/// Return a list of matching and mounted Device instances that have been searched for UpdateBundles in a Result -async fn mount_and_search_devices( - connection: &Connection, - device_regex: &str, - bundle_extension: &str, - override_dir: &str, -) -> Result, Error> { - println!("Searching for compatible block devices..."); - let mut devices = UdisksInfo::get_block_devices(connection, device_regex).await?; - - for device in &mut devices[..] { - match device.mount_filesystem(connection).await { - Ok(_path) => { - // gather PathBufs of update bundles - if let Err(error) = device.find_bundles(bundle_extension).await { - eprintln!("{}", error) - } - - // gather PathBufs of override update bundles - if let Err(error) = device - .find_override_bundles(bundle_extension, Path::new(&override_dir)) - .await - { - eprintln!("{}", error) - } - } - Err(error) => eprintln!("{}", error), - } - } - Ok(devices) -} - -/// Get an optional UpdateBundle to update to in a Result -async fn get_update_bundle( - connection: &Connection, - rauc_info: &RaucInfo, - devices: &[Device], -) -> Result, Error> { - println!("Search for compatible RAUC update bundle..."); - // get paths to all override bundles - let override_bundle_paths: Vec = devices - .iter() - .filter_map(|x| x.override_bundles()) - .flatten() - .collect(); - - match override_bundle_paths.len() { - 0 => {} - // install override bundle - 1 => match UpdateBundle::new(&override_bundle_paths[0], true, connection).await { - Ok(bundle) => { - if bundle.compatible() == rauc_info.compatible() { - return Ok(Some(bundle)); - } else { - eprintln!( - "Update bundle {} is not compatible with this system!", - bundle.path() - ) - } - } - Err(error) => eprintln!("{}", error), - }, - // error if there is more than one override bundle - _ => return Err(Error::TooManyOverrides(override_bundle_paths)), - } - - // get paths to all top-level bundles - let bundle_paths: Vec = devices - .iter() - .filter_map(|x| x.bundles()) - .flatten() - .collect(); - - if !bundle_paths.is_empty() { - let mut bundles = vec![]; - for path in bundle_paths { - match UpdateBundle::new(&path, false, connection).await { - Ok(bundle) => { - println!("Found update bundle: {}", bundle.path()); - // add bundle only if it is compatible and if its version is higher than the current - if bundle.compatible() == rauc_info.compatible() { - if rauc_info.version().is_none() - || rauc_info.version().is_some_and(|x| bundle.version().gt(x)) - { - println!( - "Adding update bundle {} to list of compatible bundles...", - bundle.path() - ); - bundles.push(bundle); - } else { - eprintln!("Update bundle {} is compatible, but its version ({}) is lower or equal to the current ({})", bundle.path(), bundle.version(), rauc_info.version_string()); - } - } else { - eprintln!("Update bundle {} is not compatible!", bundle.path()); - } - } - Err(error) => eprintln!("{}", error), - } - } - - if bundles.is_empty() { - Ok(None) - } else { - // sort by version - bundles.sort(); - bundles.reverse(); - println!("Selecting update bundle {}...", bundles[0].path()); - Ok(Some(bundles[0].clone())) - } - } else { - Ok(None) - } -} +mod rauc; -/// Unmount any previously mounted filesystems -async fn unmount_filesystems(connection: &Connection, devices: Vec) -> Result<(), Error> { - for mut device in devices { - if device.is_mounted() { - device.unmount_filesystem(connection).await?; - } - } - Ok(()) -} +use dbus::Caterpillar; +use error::Error; #[tokio::main] pub async fn main() -> Result<(), Error> { @@ -202,69 +25,32 @@ pub async fn main() -> Result<(), Error> { env!("CARGO_PKG_VERSION") ); - let mut state = State::Searching; - let connection = Connection::system().await?; - let config = read_config().await?; - println!( - "{:?}", - &config - .clone() - .try_deserialize::>() - .unwrap() - ); - let (_, rauc_info, login_proxy) = get_connections(&connection).await?; - - let devices = mount_and_search_devices( - &connection, - &config.get_string("device_regex")?, - &config.get_string("bundle_extension")?, - &config.get_string("override_dir")?, - ) - .await?; - - let result = match get_update_bundle(&connection, &rauc_info, &devices).await { - Ok(Some(bundle)) => { - println!( - "Found {}update {}", - if bundle.is_override() { - "override " - } else { - " " - }, - bundle.path() - ); - state = State::Updating; - bundle.install(&connection).await?; - Ok((bundle.path(), bundle.is_override())) - } - Ok(None) => Err(Error::NoUpdateBundle), - Err(error) => Err(error), - }; - - match result { - Ok((bundle_path, is_override)) => { - state = State::Updated; - println!("Update successful!"); - - // rename override bundle, so that it will not be installed again - if is_override { - println!("Disabling override bundle {}", bundle_path); - rename(&bundle_path, format!("{}.installed", &bundle_path))?; - } - } - Err(error) => eprintln!("{}", error), + let caterpillar = Caterpillar::new(Event::new()).await?; + let mut listener = caterpillar.done().listen(); + let autorun = caterpillar.config().get_bool("autorun")?; + + println!("Making Caterpillar available on D-Bus"); + let connection = ConnectionBuilder::system()? + .name("de.sleepmap.Caterpillar")? + .serve_at("/de/sleepmap/Caterpillar", caterpillar)? + .build() + .await?; + + // autorun caterpillar + if autorun { + println!("Non-interactive mode on first run"); + connection + .call_method( + Some(BusName::try_from("de.sleepmap.Caterpillar").unwrap()), + ObjectPath::try_from("/de/sleepmap/Caterpillar").unwrap(), + Some(InterfaceName::try_from("de.sleepmap.Caterpillar").unwrap()), + "SearchForUpdate", + &(), + ) + .await?; } - unmount_filesystems(&connection, devices).await?; - - if state == State::Updated && config.get_bool("reboot")? { - println!("Rebooting..."); - login_proxy.reboot(false).await?; - } - - if state == State::Searching { - println!("No compatible updates found. Exiting...") - } + listener.as_mut().wait(); Ok(()) } diff --git a/tests/mkosi/ab_image/mkosi.extra/etc/systemd/system/caterpillar.service b/tests/mkosi/ab_image/mkosi.extra/etc/systemd/system/caterpillar.service index 4a56ba8..f472eab 100644 --- a/tests/mkosi/ab_image/mkosi.extra/etc/systemd/system/caterpillar.service +++ b/tests/mkosi/ab_image/mkosi.extra/etc/systemd/system/caterpillar.service @@ -3,6 +3,9 @@ [Unit] After=evaluate-tests.service mnt.mount +ConditionPathExists=/usr/share/dbus-1/system-services/de.pengutronix.rauc.service +ConditionPathExists=/usr/share/dbus-1/system-services/org.freedesktop.UDisks2.service +ConditionPathExists=/usr/share/dbus-1/system-services/org.freedesktop.login1.service ConditionFileIsExecutable=/mnt/caterpillar ConditionCredential=test_environment Description=Update system with updates found on attached block devices @@ -11,7 +14,11 @@ OnSuccess=reevaluate-tests.service Wants=evaluate-tests.service mnt.mount [Service] +BusName=de.sleepmap.Caterpillar ExecStart=/mnt/caterpillar +RuntimeDirectory=caterpillar +Type=dbus [Install] +Alias=dbus-de.sleepmap.Caterpillar.service WantedBy=multi-user.target diff --git a/tests/mkosi/ab_image/mkosi.extra/usr/share/dbus-1/system-services/de.sleepmap.Caterpillar.service b/tests/mkosi/ab_image/mkosi.extra/usr/share/dbus-1/system-services/de.sleepmap.Caterpillar.service new file mode 100644 index 0000000..3f07008 --- /dev/null +++ b/tests/mkosi/ab_image/mkosi.extra/usr/share/dbus-1/system-services/de.sleepmap.Caterpillar.service @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2023 David Runge +# SPDX-License-Identifier: LGPL-3.0-or-later + +[D-BUS Service] +Name=de.sleepmap.caterpillar +Exec=/mnt/caterpillar +User=root +SystemdService=caterpillar.service diff --git a/tests/mkosi/ab_image/mkosi.extra/usr/share/dbus-1/system.d/de.sleepmap.Caterpillar.conf b/tests/mkosi/ab_image/mkosi.extra/usr/share/dbus-1/system.d/de.sleepmap.Caterpillar.conf new file mode 100644 index 0000000..4812aea --- /dev/null +++ b/tests/mkosi/ab_image/mkosi.extra/usr/share/dbus-1/system.d/de.sleepmap.Caterpillar.conf @@ -0,0 +1,13 @@ + + + + + + + + + + + From ecf978999e62ce9d1d5ca244b2bed0ea4a9baf3e Mon Sep 17 00:00:00 2001 From: David Runge Date: Thu, 28 Sep 2023 00:35:28 +0200 Subject: [PATCH 2/5] docs(README.md): Update documentation on new interactive mode Add documentation on newly added D-Bus interface and the exposed interactive mode. Add various code snippets explaining how to navigate the installation of updates using the new interface. Signed-off-by: David Runge --- README.md | 155 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 142 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 955fc36..2bab9c5 100644 --- a/README.md +++ b/README.md @@ -8,37 +8,166 @@ SPDX-License-Identifier: CC-BY-SA-4.0 A tool for the detection and installation of [RAUC](https://rauc.readthedocs.io/en/latest/) update bundles found on attached block devices. RAUC is a way to update firmware on embedded devices. -Caterpillar makes use of [dbus](https://gitlab.freedesktop.org/dbus/dbus) to communicate with -* [udisks2](https://github.com/storaged-project/udisks/) (for enumeration and (un)mounting of block devices) +Caterpillar makes use of [D-Bus](https://gitlab.freedesktop.org/dbus/dbus) to communicate with +* [UDisks2](https://github.com/storaged-project/udisks/) (for enumeration and (un)mounting of block devices) * [RAUC](https://github.com/rauc/rauc/) (for validation and installation of update bundles) * [logind](https://github.com/systemd/systemd) (for reboot after successful installation) -The application automatically detects all attached block devices and mounts them. +The application also exposes its own [D-Bus interface](./dist/dbus/de.sleepmap.Caterpillar.xml). More information on how to use it can be found in the [interactive update](#Interactive_update) section. -By default only compatible RAUC update bundles with a version higher than the current are considered and installed. -When making use of a (configurable) override directory, `caterpillar` is able to apply compatible bundles of any version (e.g. for downgrade scenarios). +## Configuration -When installation has been successful `caterpillar` unmounts all previously mounted devices and triggers a reboot of the machine (to boot into the other slot). +Some aspects of `caterpillar`'s behavior can be configured using a [configuration file](./dist/config/caterpillar.toml) in `/etc/caterpillar/caterpillar.toml`. +It is also possible to override behavior using environment variables in all caps, prefixed with `CATERPILLAR_` (e.g. `autorun = true` -> `CATERPILLAR_AUTORUN=true`). ## Use-cases +Caterpillar supports two modes of operation, non-interactive and interactive, which are explained in more detail in the sections below. + +The application is run in the background using the [`caterpillar.service`](./dist/systemd/caterpillar.service) systemd unit. +Other applications running as `root` can communicate with it over D-Bus. + +Caterpillar takes care of detecting all attached block devices and mounts compatible filesystems found on them. +In the top-level directory of each mounted filesystem it searches for compatible RAUC update bundles with a version higher than the current system version and allows for installing them. +When placing a single compatible bundle in a configurable override directory, `caterpillar` is able to install bundles of lower version as well. + +**NOTE**: Only [semver](https://crates.io/crates/semver) version comparison is supported! + +After successful update, `caterpillar` unmounts all previously mounted devices and can optionally trigger a reboot of the system (to boot into the updated system). + A rough overview of `caterpillar`'s interaction with `rauc` and `udisks2` is outlined in the below diagram: ![An overview graph of the caterpillar process in a boot scenario](./docs/overview.svg) -## Configuration +### Interactive update + +Caterpillar exposes a D-Bus interface, which allows external applications running as `root` to communicate with it. + +#### Introspection + +The application starts in `idle` mode, waiting on external input. + +```shell +[root@system ~]# busctl introspect de.sleepmap.Caterpillar /de/sleepmap/Caterpillar de.sleepmap.Caterpillar +NAME TYPE SIGNATURE RESULT/VALUE FLAGS +.InstallUpdate method bb - - +.SearchForUpdate method - - - +.MarkedForReboot property b false emits-change +.State property s "idle" emits-change +.Updated property b false emits-change +.UpdateFound signal a(sssb) - - +``` + +#### Searching for updates + +**NOTE**: It is advised to subscribe to the `UpdateFound` signal, which will propagate a found update. + +Using the `SearchForUpdate` method, `caterpillar` can be requested to search for compatible updates: + +```shell +[root@system ~]# busctl call de.sleepmap.Caterpillar /de/sleepmap/Caterpillar de.sleepmap.Caterpillar SearchForUpdate +``` -Some aspects of `caterpillar`'s behavior can be configured using a configuration file in `/etc/caterpillar/caterpillar.toml`. -An example configuration file with the defaults can be found in the `dist` directory of this repository. +If a compatible update is found, `caterpillar`'s `State` property changes to `updatefound` (`noupdatefound`, if no update is found, shortly after which it unmounts mounted devices again and returns to `idle`). + +```shell +[root@system ~]# busctl introspect de.sleepmap.Caterpillar /de/sleepmap/Caterpillar de.sleepmap.Caterpillar +NAME TYPE SIGNATURE RESULT/VALUE FLAGS +.InstallUpdate method bb - - +.SearchForUpdate method - - - +.MarkedForReboot property b false emits-change +.State property s "updatefound" emits-change +.Updated property b false emits-change +.UpdateFound signal a(sssb) - - +``` + +The `UpdateFound` signal is emitted, providing an array of length one with information on the available update: +* absolute path of update file (s) +* current version (s) +* new version (s) +* whether the update is an override (b) + +```shell +[root@system ~]# dbus-monitor --system "type='signal',path='/de/sleepmap/Caterpillar',interface='de.sleepmap.Caterpillar',member='UpdateFound'" +signal time=1695853835.109057 sender=:1.37 -> destination=(null destination) serial=8 path=/de/sleepmap/Caterpillar; interface=de.sleepmap.Caterpillar; member=UpdateFound + array [ + struct { + string "/run/media/root/bundle_disk_btrfs/update.raucb" + string "0.0.0" + string "1.0.0" + boolean false + } + ] +``` + +#### Installing updates + +Using the `InstallUpdate` method, `caterpillar` can be triggered to either install (and optionally reboot) or skip a found update. + +When requesting to skip the update and not reboot (requesting to reboot has no effect when not also updating), `caterpillar` unmounts all previously mounted devices and returns to its `idle` state (with the `Updated` property unchanged). +```shell +[root@system ~]# busctl call de.sleepmap.Caterpillar /de/sleepmap/Caterpillar de.sleepmap.Caterpillar InstallUpdate bb false false +[root@system ~]# busctl introspect de.sleepmap.Caterpillar /de/sleepmap/Caterpillar de.sleepmap.Caterpillar +NAME TYPE SIGNATURE RESULT/VALUE FLAGS +.InstallUpdate method bb - - +.SearchForUpdate method - - - +.MarkedForReboot property b false emits-change +.State property s "idle" emits-change +.Updated property b false emits-change +.UpdateFound signal a(sssb) - - +``` + +When requested to update but not reboot, `caterpillar` updates the system, unmounts all previously mounted devices and returns to its `idle` state, setting its `Updated` property to `true` on successful update. +```shell +[root@system ~]# busctl call de.sleepmap.Caterpillar /de/sleepmap/Caterpillar de.sleepmap.Caterpillar InstallUpdate bb true false +[root@system ~]# busctl introspect de.sleepmap.Caterpillar /de/sleepmap/Caterpillar de.sleepmap.Caterpillar +NAME TYPE SIGNATURE RESULT/VALUE FLAGS +.InstallUpdate method bb - - +.SearchForUpdate method - - - +.MarkedForReboot property b false emits-change +.State property s "updating" emits-change +.Updated property b false emits-change +.UpdateFound signal a(sssb) - - +[root@system ~]# busctl introspect de.sleepmap.Caterpillar /de/sleepmap/Caterpillar de.sleepmap.Caterpillar +NAME TYPE SIGNATURE RESULT/VALUE FLAGS +.InstallUpdate method bb - - +.SearchForUpdate method - - - +.MarkedForReboot property b false emits-change +.State property s "idle" emits-change +.Updated property b true emits-change +.UpdateFound signal a(sssb) - - +``` + +When requested to update and reboot, `caterpillar` updates the system, unmounts all previously mounted devices and goes to `done` state. Its `Updated` and `MarkedForReboot` properties are both set to `true`. + +```shell +[root@system ~]# busctl call de.sleepmap.Caterpillar /de/sleepmap/Caterpillar de.sleepmap.Caterpillar InstallUpdate bb true false +[root@system ~]# busctl introspect de.sleepmap.Caterpillar /de/sleepmap/Caterpillar de.sleepmap.Caterpillar +NAME TYPE SIGNATURE RESULT/VALUE FLAGS +.InstallUpdate method bb - - +.SearchForUpdate method - - - +.MarkedForReboot property b true emits-change +.State property s "updating" emits-change +.Updated property b false emits-change +.UpdateFound signal a(sssb) - - +[root@system ~]# busctl introspect de.sleepmap.Caterpillar /de/sleepmap/Caterpillar de.sleepmap.Caterpillar +NAME TYPE SIGNATURE RESULT/VALUE FLAGS +.InstallUpdate method bb - - +.SearchForUpdate method - - - +.MarkedForReboot property b true emits-change +.State property s "done" emits-change +.Updated property b true emits-change +.UpdateFound signal a(sssb) - - +``` -## Update during Boot +### Non-interactive update during boot -Caterpillar can be run in the context of a systemd service during boot (an example unit file can be found in the `dist` directory of this repository). +Caterpillar can be configured to run non-interactively the first time it is run, using the `autorun` configuration option. In this mode the application will automatically (without user input): -* detect and mount all compatible block devices +* detect all block devices and mount compatible filesystems * search and select one compatible update bundle - * if a (top-level) override directory is found in the mountpoint, a singular update bundle from it is selected + * if a (top-level) override directory with a single update bundle in it is found in the mountpoint * if more than one update bundle exists in the (top-level) directory of the mountpoint, the one with the highest version is selected * install the selected update bundle * reboot From cfbd8713b17adb1523c506842def14d3bcd058a3 Mon Sep 17 00:00:00 2001 From: David Runge Date: Thu, 28 Sep 2023 00:38:03 +0200 Subject: [PATCH 3/5] test(mkosi): Add further tools useful for debugging to images Add htop and tmux to list of installed packages in the created images. Signed-off-by: David Runge --- tests/mkosi/base_image/mkosi.conf | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/mkosi/base_image/mkosi.conf b/tests/mkosi/base_image/mkosi.conf index ef2e90d..62d1592 100644 --- a/tests/mkosi/base_image/mkosi.conf +++ b/tests/mkosi/base_image/mkosi.conf @@ -16,6 +16,7 @@ Packages= btrfs-progs dosfstools efibootmgr + htop jq linux openssh @@ -23,6 +24,7 @@ Packages= squashfs-tools sudo systemd + tmux tree udisks2 RootPassword=root From 6ff7f1771c2c6e26ca3bee47a33a0b733da57678 Mon Sep 17 00:00:00 2001 From: David Runge Date: Thu, 28 Sep 2023 12:10:03 +0200 Subject: [PATCH 4/5] test(integration): Remove test for skipping devices without update As caterpillar is now a long-running service, it never quits. This means that currently the tests for skipping on "no update found" can not be run, as they relied on the application exiting at some point. Signed-off-by: David Runge --- tests/integration.rs | 57 -------------------------------------------- 1 file changed, 57 deletions(-) diff --git a/tests/integration.rs b/tests/integration.rs index 49f8de4..66efe0c 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -218,60 +218,3 @@ fn integration_success_override( Ok(()) } - -#[rstest] -#[case(FileSystem::Btrfs)] -#[case(FileSystem::Ext4)] -#[case(FileSystem::Vfat)] -#[file_serial] -fn integration_skip_empty( - cmd_qemu_img: Result, - cmd_qemu_system: Result, - cmd_guestmount: Result, - cmd_guestunmount: Result, - input_path_ovmf_code: Result, - ab_image: Result, - ovmf_vars: Result, - bundle_disks: Result, TestError>, - rauc_bundles: Result, TestError>, - #[case] filesystem: FileSystem, -) -> TestResult { - let name = "skip_empty"; - let disk_type = DiskType::Empty; - - let qemu_img = cmd_qemu_img?; - let qemu_system = cmd_qemu_system?; - let guestmount = cmd_guestmount?; - let guestunmount = cmd_guestunmount?; - let ovmf_vars = ovmf_vars?; - let update_bundles = rauc_bundles?; - let bundle_disk = match bundle_disks? - .iter() - .find(|x| x.filesystem().eq(&filesystem) && x.disk_type().eq(&disk_type)) - { - Some(bundle) => bundle.clone(), - None => return Err(testresult::TestError::from("foo")), - }; - - let test_image = ab_image?; - println!("Built ab_image: {:?}", &test_image); - test_image.prepare_for_test(&qemu_img, &guestmount, &guestunmount)?; - - bundle_disk.prepare_test(&qemu_img, &guestmount, &guestunmount, vec![])?; - - println!("Created OVMF vars: {}", ovmf_vars.display()); - println!("Using bundle disk: {:?}", bundle_disk.path().display()); - println!("Created RAUC bundles: {:?}", update_bundles); - - run_test( - &qemu_system, - &qemu_img, - input_path_ovmf_code?, - ovmf_vars, - test_image, - bundle_disk, - name, - )?; - - Ok(()) -} From 82bbf2b2f7362d4731db40f07016d193616bfe26 Mon Sep 17 00:00:00 2001 From: David Runge Date: Thu, 28 Sep 2023 12:27:28 +0200 Subject: [PATCH 5/5] chore(Cargo.toml): Remove unused dependencies Remove dbus-udisks2 and version-compare as they are unused. Signed-off-by: David Runge --- Cargo.lock | 68 ------------------------------------------------------ Cargo.toml | 2 -- 2 files changed, 70 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a81f7b6..0d2dd2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -364,7 +364,6 @@ dependencies = [ "async-std", "config", "dbus-launch", - "dbus-udisks2", "event-listener 3.0.0", "fslock", "futures", @@ -383,7 +382,6 @@ dependencies = [ "tmpdir", "tokio", "tracing", - "version-compare", "which", "zbus", "zbus_macros", @@ -480,19 +478,6 @@ dependencies = [ "parking_lot_core", ] -[[package]] -name = "dbus" -version = "0.9.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bb21987b9fb1613058ba3843121dd18b163b254d8a6e797e144cbac14d96d1b" -dependencies = [ - "futures-channel", - "futures-util", - "libc", - "libdbus-sys", - "winapi", -] - [[package]] name = "dbus-launch" version = "0.2.0" @@ -503,17 +488,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "dbus-udisks2" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "962d6f1be8f08a184fb98e63ad580ad766981454b5f91f102370fc99057d77e2" -dependencies = [ - "dbus", - "futures-util", - "num_enum", -] - [[package]] name = "derivative" version = "2.2.0" @@ -926,15 +900,6 @@ version = "0.2.147" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" -[[package]] -name = "libdbus-sys" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06085512b750d640299b79be4bad3d2fa90a9c00b1fd9e1b46364f66f0485c72" -dependencies = [ - "pkg-config", -] - [[package]] name = "linked-hash-map" version = "0.5.6" @@ -1054,27 +1019,6 @@ dependencies = [ "libc", ] -[[package]] -name = "num_enum" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f646caf906c20226733ed5b1374287eb97e3c2a5c227ce668c1f2ce20ae57c9" -dependencies = [ - "num_enum_derive", -] - -[[package]] -name = "num_enum_derive" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "object" version = "0.32.1" @@ -1202,12 +1146,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "pkg-config" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" - [[package]] name = "polling" version = "2.8.0" @@ -1876,12 +1814,6 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92ccd67fb88503048c01b59152a04effd0782d035a83a6d256ce6085f08f4a3" -[[package]] -name = "version-compare" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29" - [[package]] name = "version_check" version = "0.9.4" diff --git a/Cargo.toml b/Cargo.toml index 5c264e4..6e7afec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,6 @@ codegen-units = 1 [dependencies] async-std = {version = "1.12.0", features = ["attributes", "tokio1"]} config = "0.13.3" -dbus-udisks2 = {version = "0.3.0", features = ["futures"]} event-listener = "3.0.0" futures = "0.3.28" once_cell = "1.17.1" @@ -36,7 +35,6 @@ strum = {version = "0.24.1", features = ["derive"]} strum_macros = "0.24.3" thiserror = "1.0.47" tokio = {version = "1.28.0", features = ["rt-multi-thread", "macros"]} -version-compare = "0.1.1" zbus = {version = "3.12.0", default-features = false, features = ["tokio"]} zbus_macros = "3.14.1" zvariant = "3.12.0"