diff --git a/openapi.yaml b/openapi.yaml index 7451efcb..1d6fae61 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -697,6 +697,7 @@ paths: - network-manager - labgrid-exporter - lxa-iobus + - rauc put: summary: Perform an action on a systemd service tags: [System] @@ -722,6 +723,7 @@ paths: - network-manager - labgrid-exporter - lxa-iobus + - rauc get: summary: Get the status of a systemd service tags: [System] @@ -773,6 +775,21 @@ paths: '400': description: The value could not be parsed as boolean + /v1/tac/update/enable_auto_install: + put: + summary: Enable automatic installation of operating system updates + tags: [Updating] + requestBody: + content: + application/json: + schema: + type: boolean + responses: + '204': + description: Automatic installation of updates was enabled/disabled + '400': + description: The value could not be parsed as boolean + /v1/tac/update/operation: get: summary: Get the currently running system update operation @@ -825,12 +842,12 @@ paths: content: application/json: schema: - type: string + $ref: '#/components/schemas/UpdateRequest' responses: '204': - description: The value was parsed as string and will be tried + description: The value was parsed successfully and will be tried '400': - description: The value could not be parsed as string + description: The value could not be parsed /v1/tac/update/channels: get: @@ -1100,6 +1117,14 @@ components: nesting_depth: type: number + UpdateRequest: + type: object + properties: + url: + type: string + manifest_hash: + type: string + UpdateChannels: type: array items: @@ -1122,6 +1147,8 @@ components: type: integer enabled: type: boolean + primary: + type: boolean bundle: type: object properties: @@ -1129,8 +1156,22 @@ components: type: string version: type: string, + manifest_hash: + type: string, + effective_url: + type: string, newer_than_installed: type: boolean + force_polling: + type: boolean + force_auto_install: + type: boolean + candidate_criteria: + type: string + install_criteria: + type: string + reboot_criteria: + type: string ServiceStatus: type: object diff --git a/src/broker/topic.rs b/src/broker/topic.rs index 3a0c2c96..ef32e98a 100644 --- a/src/broker/topic.rs +++ b/src/broker/topic.rs @@ -336,18 +336,6 @@ impl Topic { self.modify(|prev| if prev != msg { msg } else { None }); } - - /// Wait until the topic is set to the specified value - pub async fn wait_for(self: &Arc, val: E) { - let (mut stream, sub) = self.clone().subscribe_unbounded(); - - // Unwrap here to keep the interface simple. The stream could only yield - // None if the sender side is dropped, which will not happen as we hold - // an Arc to self which contains the senders vec. - while stream.next().await.unwrap() != val {} - - sub.unsubscribe() - } } impl> Topic { diff --git a/src/dbus.rs b/src/dbus.rs index aa8e333c..db9c4b55 100644 --- a/src/dbus.rs +++ b/src/dbus.rs @@ -82,6 +82,7 @@ impl DbusSession { wtb: &mut WatchedTasksBuilder, led_dut: Arc>, led_uplink: Arc>, + setup_mode: Arc>, ) -> anyhow::Result { let tacd = Tacd::new(); @@ -89,11 +90,13 @@ impl DbusSession { let conn = Arc::new(tacd.serve(conn_builder).build().await?); + let systemd = Systemd::new(bb, wtb, &conn).await?; + Ok(Self { hostname: Hostname::new(bb, wtb, &conn)?, network: Network::new(bb, wtb, &conn, led_dut, led_uplink)?, - rauc: Rauc::new(bb, wtb, &conn)?, - systemd: Systemd::new(bb, wtb, &conn).await?, + rauc: Rauc::new(bb, wtb, &conn, systemd.rauc.clone(), setup_mode)?, + systemd, }) } } diff --git a/src/dbus/rauc.rs b/src/dbus/rauc.rs index 415f7710..11bf60fb 100644 --- a/src/dbus/rauc.rs +++ b/src/dbus/rauc.rs @@ -15,24 +15,25 @@ // with this program; if not, write to the Free Software Foundation, Inc., // 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -use std::cmp::Ordering; use std::collections::HashMap; -use std::time::{Duration, Instant}; use anyhow::Result; -use async_std::channel::Receiver; use async_std::stream::StreamExt; use async_std::sync::Arc; -use async_std::task::{sleep, spawn, JoinHandle}; -use log::warn; +use futures_util::FutureExt; +use log::{error, info, warn}; use serde::{Deserialize, Serialize}; +use super::systemd::{Service, ServiceAction}; use super::Connection; use crate::broker::{BrokerBuilder, Topic}; use crate::watched_tasks::WatchedTasksBuilder; mod update_channels; -pub use update_channels::Channel; +pub use update_channels::{Channel, Channels}; + +mod system_conf; +use system_conf::update_system_conf; #[cfg(feature = "demo_mode")] mod demo_mode; @@ -43,55 +44,38 @@ mod installer; #[cfg(not(feature = "demo_mode"))] use installer::InstallerProxy; +#[cfg(not(feature = "demo_mode"))] +mod poller; + +#[cfg(not(feature = "demo_mode"))] +use poller::PollerProxy; + #[cfg(feature = "demo_mode")] mod imports { - use std::collections::HashMap; + pub(super) const CHANNELS_DIR: &str = "demo_files/usr/share/tacd/update_channels"; - pub(super) struct InstallerProxy<'a> { + pub(super) struct PollerProxy<'a> { _dummy: &'a (), } - impl<'a> InstallerProxy<'a> { - pub async fn new(_conn: C) -> Option> { + impl PollerProxy<'_> { + pub async fn new(_conn: C) -> Option { Some(Self { _dummy: &() }) } - pub async fn inspect_bundle( - &self, - _source: &str, - _args: HashMap<&str, zbus::zvariant::Value<'_>>, - ) -> zbus::Result> { - let update: HashMap = [ - ( - "compatible".into(), - "Linux Automation GmbH - LXA TAC".into(), - ), - ("version".into(), "24.04-20240415070800".into()), - ] - .into(); - - let info: HashMap = - [("update".into(), update.into())].into(); - - Ok(info) + pub async fn poll(&self) -> zbus::Result<()> { + Ok(()) } } - - pub(super) const CHANNELS_DIR: &str = "demo_files/usr/share/tacd/update_channels"; } #[cfg(not(feature = "demo_mode"))] mod imports { pub(super) use anyhow::bail; - pub(super) use log::error; pub(super) const CHANNELS_DIR: &str = "/usr/share/tacd/update_channels"; } -const RELOAD_RATE_LIMIT: Duration = Duration::from_secs(10 * 60); -const RETRY_INTERVAL_MIN: Duration = Duration::from_secs(60); -const RETRY_INTERVAL_MAX: Duration = Duration::from_secs(60 * 60); - use imports::*; #[derive(Serialize, Deserialize, Clone)] @@ -111,6 +95,37 @@ impl From<(i32, String, i32)> for Progress { } } +#[derive(Serialize, Deserialize, Clone)] +#[serde(from = "UpdateRequestDe")] +pub struct UpdateRequest { + pub manifest_hash: Option, + pub url: Option, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum UpdateRequestDe { + UrlAndHash { + manifest_hash: Option, + url: Option, + }, + UrlOnly(String), +} + +impl From for UpdateRequest { + fn from(de: UpdateRequestDe) -> Self { + // Provide API backward compatibility by allowing either just a String + // as argument or a map with url and manifest hash inside. + match de { + UpdateRequestDe::UrlAndHash { manifest_hash, url } => Self { manifest_hash, url }, + UpdateRequestDe::UrlOnly(url) => Self { + manifest_hash: None, + url: Some(url), + }, + } + } +} + type SlotStatus = HashMap>; pub struct Rauc { @@ -120,22 +135,12 @@ pub struct Rauc { #[cfg_attr(feature = "demo_mode", allow(dead_code))] pub primary: Arc>, pub last_error: Arc>, - pub install: Arc>, - pub channels: Arc>>, + pub install: Arc>, + pub channels: Arc>, pub reload: Arc>, pub should_reboot: Arc>, pub enable_polling: Arc>, -} - -fn compare_versions(v1: &str, v2: &str) -> Option { - // Version strings look something like this: "4.0-0-20230428214619" - // Use string sorting on the date part to determine which bundle is newer. - let date_1 = v1.rsplit_once('-').map(|(_, d)| d); - let date_2 = v2.rsplit_once('-').map(|(_, d)| d); - - // Return Sone if either version could not be split or a Some with the - // ordering between the dates. - date_1.zip(date_2).map(|(d1, d2)| d1.cmp(d2)) + pub enable_auto_install: Arc>, } #[cfg(not(feature = "demo_mode"))] @@ -197,132 +202,104 @@ fn would_reboot_into_other_slot(slot_status: &SlotStatus, primary: Option, + reload: Arc>, enable_polling: Arc>, - channels: Arc>>, - slot_status: Arc>>, - name: String, -) { - let proxy = InstallerProxy::new(&conn).await.unwrap(); - - let mut retry_interval = RETRY_INTERVAL_MIN; - - while let Some(mut channel) = channels - .try_get() - .and_then(|chs| chs.into_iter().find(|ch| ch.name == name)) - { - // Make sure update polling is enabled before doing anything, - // as contacting the update server requires user consent. - enable_polling.wait_for(true).await; - - let polling_interval = channel.polling_interval; - let slot_status = slot_status.try_get(); - - if let Err(e) = channel.poll(&proxy, slot_status.as_deref()).await { - warn!( - "Failed to fetch update for update channel \"{}\": {}. Retrying in {}s.", - channel.name, - e, - retry_interval.as_secs() - ); - - if retry_interval < RETRY_INTERVAL_MAX { - sleep(retry_interval).await; - - // Perform a (limited) exponential backoff on the retry interval to recover - // fast from short-term issues while also preventing the update server from - // being DDOSed by excessive retries. - retry_interval *= 2; + enable_auto_install: Arc>, + setup_mode: Arc>, + channels: Arc>, + rauc_service: Service, +) -> Result<()> { + let poller = PollerProxy::new(&conn).await.unwrap(); + + let (reload_stream, _) = reload.subscribe_unbounded(); + let (mut enable_polling_stream, _) = enable_polling.subscribe_unbounded(); + let (mut enable_auto_install_stream, _) = enable_auto_install.subscribe_unbounded(); + let (mut setup_mode_stream, _) = setup_mode.subscribe_unbounded(); + + let mut enable_polling = enable_polling_stream.next().await.unwrap_or(false); + let mut enable_auto_install = enable_auto_install_stream.next().await.unwrap_or(false); + let mut setup_mode = setup_mode_stream.next().await.unwrap_or(true); + + 'reload_loop: loop { + futures::select! { + reload = reload_stream.recv().fuse() => { + if !(reload?) { + continue 'reload_loop + } + } + enable_polling_new = enable_polling_stream.recv().fuse() => { + enable_polling = enable_polling_new?; + } + enable_auto_install_new = enable_auto_install_stream.recv().fuse() => { + enable_auto_install = enable_auto_install_new?; + } + setup_mode_new = setup_mode_stream.recv().fuse() => { + setup_mode = setup_mode_new?; + } + }; + // Read the list of available update channels + let new_channels = match Channels::from_directory(CHANNELS_DIR) { + Ok(chs) => chs, + Err(e) => { + warn!("Failed to get list of update channels: {e}"); continue; } - } + }; - retry_interval = RETRY_INTERVAL_MIN; + let should_reload = update_system_conf( + new_channels.primary(), + enable_polling, + enable_auto_install, + setup_mode, + )?; - channels.modify(|chs| { - let mut chs = chs?; - let channel_prev = chs.iter_mut().find(|ch| ch.name == name)?; + channels.set(new_channels); - // Check if the bundle we polled is the same as before and we don't need - // to send a message to the subscribers. - if *channel_prev == channel { - return None; - } + if should_reload { + info!("New RAUC config written. Triggering daemon restart."); - // Update the channel description with the newly polled bundle info - *channel_prev = channel; + let (mut status, status_subscription) = + rauc_service.status.clone().subscribe_unbounded(); + rauc_service.action.set(ServiceAction::Restart); - Some(chs) - }); + info!("Waiting for daemon to go down"); - match polling_interval { - Some(pi) => sleep(pi).await, - None => break, - } - } -} + while let Some(ev) = status.next().await { + info!("Current status: {} ({})", ev.active_state, ev.sub_state); -async fn channel_list_update_task( - conn: Arc, - mut reload_stream: Receiver, - enable_polling: Arc>, - channels: Arc>>, - slot_status: Arc>>, -) -> Result<()> { - let mut previous: Option = None; - let mut polling_tasks: Vec> = Vec::new(); + if ev.active_state != "active" { + break; + } + } - while let Some(reload) = reload_stream.next().await { - if !reload { - continue; - } + info!("Waiting for daemon to come up again"); - // Polling for updates is a somewhat expensive operation. - // Make sure it can not be abused to DOS the tacd. - if previous - .map(|p| p.elapsed() < RELOAD_RATE_LIMIT) - .unwrap_or(false) - { - continue; - } + while let Some(ev) = status.next().await { + info!("Current status: {} ({})", ev.active_state, ev.sub_state); - // Read the list of available update channels - let new_channels = match Channel::from_directory(CHANNELS_DIR) { - Ok(chs) => chs, - Err(e) => { - warn!("Failed to get list of update channels: {e}"); - continue; + if ev.active_state == "active" { + break; + } } - }; - // Stop the currently running polling tasks - for task in polling_tasks.drain(..) { - task.cancel().await; - } + info!("Done"); - let names: Vec = new_channels.iter().map(|c| c.name.clone()).collect(); - - channels.set(new_channels); + status_subscription.unsubscribe(); + } else { + info!("Config is up to date. Will not reload."); + } - // Spawn new polling tasks. They will poll once immediately. - for name in names.into_iter() { - let polling_task = spawn(channel_polling_task( - conn.clone(), - enable_polling.clone(), - channels.clone(), - slot_status.clone(), - name, - )); + if enable_polling { + info!("Trigger a poll"); - polling_tasks.push(polling_task); + if let Err(err) = poller.poll().await { + error!("Failed to poll for updates: {err}"); + } } - - previous = Some(Instant::now()); } - - Ok(()) } impl Rauc { @@ -333,7 +310,7 @@ impl Rauc { slot_status: bb.topic_ro("/v1/tac/update/slots", None), primary: bb.topic_ro("/v1/tac/update/primary", None), last_error: bb.topic_ro("/v1/tac/update/last_error", None), - install: bb.topic_wo("/v1/tac/update/install", Some("".to_string())), + install: bb.topic_wo("/v1/tac/update/install", None), channels: bb.topic_ro("/v1/tac/update/channels", None), reload: bb.topic_wo("/v1/tac/update/channels/reload", Some(true)), should_reboot: bb.topic_ro("/v1/tac/update/should_reboot", Some(false)), @@ -345,6 +322,14 @@ impl Rauc { Some(false), 1, ), + enable_auto_install: bb.topic( + "/v1/tac/update/enable_auto_install", + true, + true, + true, + Some(false), + 1, + ), } } @@ -353,6 +338,8 @@ impl Rauc { bb: &mut BrokerBuilder, wtb: &mut WatchedTasksBuilder, _conn: &Arc, + rauc_service: Service, + setup_mode: Arc>, ) -> Result { let inst = Self::setup_topics(bb); @@ -361,15 +348,16 @@ impl Rauc { inst.last_error.set("".to_string()); // Reload the channel list on request - let (reload_stream, _) = inst.reload.clone().subscribe_unbounded(); wtb.spawn_task( "rauc-channel-list-update", channel_list_update_task( Arc::new(Connection), - reload_stream, + inst.reload.clone(), inst.enable_polling.clone(), + inst.enable_auto_install.clone(), + setup_mode, inst.channels.clone(), - inst.slot_status.clone(), + rauc_service, ), )?; @@ -381,6 +369,8 @@ impl Rauc { bb: &mut BrokerBuilder, wtb: &mut WatchedTasksBuilder, conn: &Arc, + rauc_service: Service, + setup_mode: Arc>, ) -> Result { let inst = Self::setup_topics(bb); @@ -388,7 +378,6 @@ impl Rauc { let operation = inst.operation.clone(); let slot_status = inst.slot_status.clone(); let primary = inst.primary.clone(); - let channels = inst.channels.clone(); let should_reboot = inst.should_reboot.clone(); wtb.spawn_task("rauc-slot-status-update", async move { @@ -451,23 +440,6 @@ impl Rauc { }) .collect(); - // Update the `newer_than_installed` field for the upstream bundles inside - // of the update channels. - channels.modify(|prev| { - let prev = prev?; - - let mut new = prev.clone(); - - for ch in new.iter_mut() { - if let Some(bundle) = ch.bundle.as_mut() { - bundle.update_install(&slots); - } - } - - // Only send out messages if anything changed - (new != prev).then_some(new) - }); - // Provide a simple yes/no "should reboot into other slot?" information // based on the bundle versions in the booted slot and the other slot. match would_reboot_into_other_slot(&slots, new_primary) { @@ -537,37 +509,126 @@ impl Rauc { })?; let conn_task = conn.clone(); + let channels = inst.channels.clone(); + + // Forward the "Poller::status" property to the broker framework + wtb.spawn_task("rauc-forward-poller-status", async move { + let proxy = PollerProxy::new(&conn_task).await.unwrap(); + + let mut stream = proxy.receive_status_changed().await; + + if let Ok(status) = proxy.status().await { + channels.modify(|chs| { + let mut chs = chs?; + + match chs.update_from_poll_status(status.into()) { + Ok(true) => Some(chs), + Ok(false) => None, + Err(e) => { + warn!("Could not update channel list from poll status: {e}"); + None + } + } + }); + } + + while let Some(status) = stream.next().await { + let status = match status.get().await { + Ok(status) => status, + Err(e) => { + warn!("Could not get poll status: {e}"); + continue; + } + }; + + channels.modify(|chs| { + let mut chs = chs?; + + match chs.update_from_poll_status(status.into()) { + Ok(true) => Some(chs), + Ok(false) => None, + Err(e) => { + warn!("Could not update channel list from poll status: {e}"); + None + } + } + }); + } + + Ok(()) + })?; + + let conn_task = conn.clone(); + let channels = inst.channels.clone(); let (mut install_stream, _) = inst.install.clone().subscribe_unbounded(); // Forward the "install" topic from the broker framework to RAUC wtb.spawn_task("rauc-forward-install", async move { let proxy = InstallerProxy::new(&conn_task).await.unwrap(); - while let Some(url) = install_stream.next().await { - // Poor-mans validation. It feels wrong to let someone point to any - // file on the TAC from the web interface. - if url.starts_with("http://") || url.starts_with("https://") { - let args = HashMap::new(); + while let Some(update_request) = install_stream.next().await { + let channels = match channels.try_get() { + Some(chs) => chs, + None => { + warn!("Got install request with no channels available yet"); + continue; + } + }; - if let Err(e) = proxy.install_bundle(&url, args).await { - error!("Failed to install bundle: {}", e); + let primary = match channels.primary() { + Some(primary) => primary, + None => { + warn!("Got install request with no primary channel configured"); + continue; + } + }; + + let upstream_bundle = match primary.bundle.as_ref() { + Some(us) => us, + None => { + warn!("Got install request with no upstream bundle info available yet"); + continue + } + }; + + let url = match &update_request.url { + None => &upstream_bundle.effective_url, + Some(url) if url == &upstream_bundle.effective_url => &upstream_bundle.effective_url, + Some(url) if url == &primary.url => &primary.url, + Some(_) => { + warn!("Got install request with URL matching neither channel URL nor effective bundle URL"); + continue; } + }; + + let manifest_hash: Option = + update_request.manifest_hash.map(|mh| mh.into()); + + let mut args = HashMap::new(); + + if let Some(manifest_hash) = &manifest_hash { + args.insert("require-manifest-hash", manifest_hash); + } + + if let Err(e) = proxy.install_bundle(url, args).await { + error!("Failed to install bundle: {}", e); } } Ok(()) })?; - // Reload the channel list on request - let (reload_stream, _) = inst.reload.clone().subscribe_unbounded(); + // Reload the channel list when required wtb.spawn_task( "rauc-channel-list-update", channel_list_update_task( conn.clone(), - reload_stream, + inst.reload.clone(), inst.enable_polling.clone(), + inst.enable_auto_install.clone(), + setup_mode, inst.channels.clone(), - inst.slot_status.clone(), + rauc_service, ), )?; diff --git a/src/dbus/rauc/installer.rs b/src/dbus/rauc/installer.rs index d8c18361..f0301612 100644 --- a/src/dbus/rauc/installer.rs +++ b/src/dbus/rauc/installer.rs @@ -10,6 +10,11 @@ use zbus::proxy; default_path = "/" )] trait Installer { + /// GetArtifactStatus method + fn get_artifact_status( + &self, + ) -> zbus::Result>>; + /// GetPrimary method fn get_primary(&self) -> zbus::Result; diff --git a/src/dbus/rauc/poller.rs b/src/dbus/rauc/poller.rs new file mode 100644 index 00000000..70fdb3ba --- /dev/null +++ b/src/dbus/rauc/poller.rs @@ -0,0 +1,24 @@ +//! This code was generated by `zbus-xmlgen` `4.1.0` from DBus introspection data. +//! +//! By running `zbus-xmlgen system de.pengutronix.rauc /` on the LXA TAC. + +use zbus::proxy; + +#[proxy( + interface = "de.pengutronix.rauc.Poller", + default_service = "de.pengutronix.rauc", + default_path = "/" +)] +trait Poller { + /// Poll method + fn poll(&self) -> zbus::Result<()>; + + /// NextPoll property + #[zbus(property)] + fn next_poll(&self) -> zbus::Result; + + /// Status property + #[zbus(property)] + fn status(&self) + -> zbus::Result>; +} diff --git a/src/dbus/rauc/system_conf.rs b/src/dbus/rauc/system_conf.rs new file mode 100644 index 00000000..fce32277 --- /dev/null +++ b/src/dbus/rauc/system_conf.rs @@ -0,0 +1,161 @@ +use std::fmt::Write; +use std::fs::{create_dir_all, read_to_string, remove_file, rename, write}; +use std::io::{Error, ErrorKind}; +use std::path::Path; + +use super::Channel; + +use log::info; + +#[cfg(feature = "demo_mode")] +mod imports { + pub(super) const STATIC_CONF_PATH: &str = "demo_files/usr/lib/rauc/system.conf"; + pub(super) const DYNAMIC_CONF_PATH: &str = "demo_files/run/rauc/system.conf"; +} + +#[cfg(not(feature = "demo_mode"))] +mod imports { + pub(super) const STATIC_CONF_PATH: &str = "/usr/lib/rauc/system.conf"; + pub(super) const DYNAMIC_CONF_PATH: &str = "/run/rauc/system.conf"; +} + +use imports::*; + +const MAGIC_LINE: &str = "\n# \n"; + +fn poll_section( + primary_channel: Option<&Channel>, + polling: bool, + auto_install: bool, +) -> Result, std::fmt::Error> { + // If no primary channel is configured or if polling is not enabled, + // then we do not need a `[poll]` section at all. + let primary_channel = match (primary_channel, polling) { + (Some(pc), true) => pc, + _ => return Ok(None), + }; + + let mut section = String::new(); + + writeln!(&mut section)?; + writeln!(&mut section, "[poll]")?; + writeln!(&mut section, "source={}", primary_channel.url)?; + + if let Some(interval) = primary_channel.polling_interval { + writeln!(&mut section, "interval-sec={}", interval.as_secs())?; + } + + let candidate_criteria = primary_channel + .candidate_criteria + .as_deref() + .unwrap_or("different-version"); + + writeln!(&mut section, "candidate-criteria={candidate_criteria}")?; + + if auto_install { + let install_criteria = primary_channel + .install_criteria + .as_deref() + .unwrap_or("different-version"); + let reboot_criteria = primary_channel + .reboot_criteria + .as_deref() + .unwrap_or("updated-slots;updated-artifacts"); + + writeln!(&mut section, "install-criteria={install_criteria}")?; + writeln!(&mut section, "reboot-criteria={reboot_criteria}")?; + writeln!(&mut section, "reboot-cmd=systemctl reboot")?; + } + + Ok(Some(section)) +} + +pub fn update_system_conf( + primary_channel: Option<&Channel>, + enable_polling: bool, + enable_auto_install: bool, + setup_mode: bool, +) -> std::io::Result { + let dynamic_conf = { + // Allow force-enabling update polling and automatic installations + // via the update channel config file to implement company wide + // auto-update policies. + let force_polling = primary_channel + .and_then(|pc| pc.force_polling) + .unwrap_or(false); + let force_auto_install = primary_channel + .and_then(|pc| pc.force_auto_install) + .unwrap_or(false); + + // Allow polling for updates, but not automatically installing them + // while the user is still in setup mode. + // Otherwise they may unbox a TAC, click through the setup process, + // activate auto installation, and then an installation starts in the + // background without them even noticing. + let polling = enable_polling || force_polling; + let auto_install = (enable_auto_install || force_auto_install) && !setup_mode; + + match poll_section(primary_channel, polling, auto_install) { + Ok(Some(ps)) => { + // We use the config in /etc as a template ... + let static_conf = read_to_string(STATIC_CONF_PATH)?; + + // ... and replace the line `# ` with our + // generated `[poll]` section. + let dc = static_conf.replacen(MAGIC_LINE, &ps, 1); + + // The user may have decided not to include a `# ` + // line. In that case we do not need a dynamic config at all. + if dc == static_conf { + info!( + "Rauc config {} did not contain magic line '{}'. Will not generate poll section.", + STATIC_CONF_PATH, MAGIC_LINE + ); + + None + } else { + Some(dc) + } + } + _ => None, + } + }; + + /* Do we need a dynamic config in /run/rauc? + * + * If so, then is it actually different from what we already have? + * If not, then there is no need to restart the daemon. + * If it is, we write the new one and signal the need for a daemon + * restart. + * + * If we do not need dynamic config, then try to delete the previous one. + * If there was none, then the daemon does not have to be restarted. + * If there was a dynamic config before, then we need to restart the + * daemon. + */ + match dynamic_conf { + Some(new) => match read_to_string(DYNAMIC_CONF_PATH) { + Ok(old) if old == new => Ok(false), + Err(err) if err.kind() != ErrorKind::NotFound => Err(err), + Ok(_) | Err(_) => { + let dynamic_conf_dir = Path::new(DYNAMIC_CONF_PATH) + .parent() + .ok_or_else(|| Error::other("Invalid dynamic config path"))?; + + let tmp_path = dynamic_conf_dir.join("system.conf.tacd-tmp"); + + create_dir_all(dynamic_conf_dir)?; + + write(&tmp_path, &new)?; + rename(&tmp_path, DYNAMIC_CONF_PATH)?; + + Ok(true) + } + }, + None => match remove_file(DYNAMIC_CONF_PATH) { + Ok(_) => Ok(true), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(false), + Err(err) => Err(err), + }, + } +} diff --git a/src/dbus/rauc/update_channels.rs b/src/dbus/rauc/update_channels.rs index 1a5fedd2..d147f6c7 100644 --- a/src/dbus/rauc/update_channels.rs +++ b/src/dbus/rauc/update_channels.rs @@ -15,7 +15,8 @@ // with this program; if not, write to the Free Software Foundation, Inc., // 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -use std::collections::HashMap; +#[cfg(not(feature = "demo_mode"))] +use std::convert::TryFrom; use std::fs::{read_dir, read_to_string, DirEntry}; use std::os::unix::ffi::OsStrExt; use std::path::Path; @@ -24,8 +25,6 @@ use std::time::Duration; use anyhow::{anyhow, bail, Result}; use serde::{Deserialize, Serialize}; -use super::{compare_versions, InstallerProxy, SlotStatus}; - #[cfg(feature = "demo_mode")] const ENABLE_DIR: &str = "demo_files/etc/rauc/certificates-enabled"; @@ -40,6 +39,8 @@ const ONE_DAY: Duration = Duration::from_secs(24 * 60 * 60); pub struct UpstreamBundle { pub compatible: String, pub version: String, + pub manifest_hash: String, + pub effective_url: String, pub newer_than_installed: bool, } @@ -51,9 +52,18 @@ pub struct Channel { pub url: String, pub polling_interval: Option, pub enabled: bool, + pub primary: bool, pub bundle: Option, + pub force_polling: Option, + pub force_auto_install: Option, + pub candidate_criteria: Option, + pub install_criteria: Option, + pub reboot_criteria: Option, } +#[derive(Serialize, Deserialize, Clone, PartialEq)] +pub struct Channels(Vec); + #[derive(Deserialize)] pub struct ChannelFile { pub name: String, @@ -61,20 +71,31 @@ pub struct ChannelFile { pub description: String, pub url: String, pub polling_interval: Option, + pub force_polling: Option, + pub force_auto_install: Option, + pub candidate_criteria: Option, + pub install_criteria: Option, + pub reboot_criteria: Option, } -fn zvariant_walk_nested_dicts(map: &zvariant::Dict, path: &[&str]) -> Result { - let (&key, rem) = path +#[cfg(not(feature = "demo_mode"))] +fn zvariant_walk_nested_dicts<'a, T>(map: &'a zvariant::Dict, path: &'a [&'a str]) -> Result<&'a T> +where + &'a T: TryFrom<&'a zvariant::Value<'a>>, + <&'a T as TryFrom<&'a zvariant::Value<'a>>>::Error: Into, +{ + let (key, rem) = path .split_first() .ok_or_else(|| anyhow!("Got an empty path to walk"))?; let value: &zvariant::Value = map - .get(&key)? + .get(key)? .ok_or_else(|| anyhow!("Could not find key \"{key}\" in dict"))?; if rem.is_empty() { value.downcast_ref().map_err(|e| { - anyhow!("Failed to convert value in dictionary for key \"{key}\" to a string: {e}") + let type_name = std::any::type_name::(); + anyhow!("Failed to convert value in dictionary for key \"{key}\" to {type_name}: {e}") }) } else { let value = value.downcast_ref().map_err(|e| { @@ -132,7 +153,13 @@ impl Channel { url: channel_file.url.trim().to_string(), polling_interval, enabled: false, + primary: false, bundle: None, + force_polling: channel_file.force_polling, + force_auto_install: channel_file.force_auto_install, + candidate_criteria: channel_file.candidate_criteria, + install_criteria: channel_file.install_criteria, + reboot_criteria: channel_file.reboot_criteria, }; ch.update_enabled(); @@ -140,7 +167,17 @@ impl Channel { Ok(ch) } - pub(super) fn from_directory(dir: &str) -> Result> { + fn update_enabled(&mut self) { + // Which channels are enabled is decided based on which RAUC certificates are enabled. + let cert_file = self.name.clone() + ".cert.pem"; + let cert_path = Path::new(ENABLE_DIR).join(cert_file); + + self.enabled = cert_path.exists(); + } +} + +impl Channels { + pub(super) fn from_directory(dir: &str) -> Result { // Find all .yaml files in CHANNELS_DIR let mut dir_entries: Vec = read_dir(dir)? .filter_map(|dir_entry| dir_entry.ok()) @@ -157,85 +194,78 @@ impl Channel { // 05_testing.yaml. dir_entries.sort_by_key(|dir_entry| dir_entry.file_name()); - let mut channels: Vec = Vec::new(); + let mut channels: Vec = Vec::new(); + + let mut have_primary = false; for dir_entry in dir_entries { - let channel = Self::from_file(&dir_entry.path())?; + let mut channel = Channel::from_file(&dir_entry.path())?; if channels.iter().any(|ch| ch.name == channel.name) { bail!("Encountered duplicate channel name \"{}\"", channel.name); } + // There can only be one primary channel. + // If multiple channels are enabled the primary one is the one with + // the highest precedence. + channel.primary = channel.enabled && !have_primary; + have_primary |= channel.primary; + channels.push(channel); } - Ok(channels) + Ok(Self(channels)) } - fn update_enabled(&mut self) { - // Which channels are enabled is decided based on which RAUC certificates are enabled. - let cert_file = self.name.clone() + ".cert.pem"; - let cert_path = Path::new(ENABLE_DIR).join(cert_file); - - self.enabled = cert_path.exists(); + pub fn into_vec(self) -> Vec { + self.0 } - /// Ask RAUC to determine the version of the bundle on the server - pub(super) async fn poll( - &mut self, - proxy: &InstallerProxy<'_>, - slot_status: Option<&SlotStatus>, - ) -> Result<()> { - self.update_enabled(); - - self.bundle = None; - - if self.enabled { - let args = HashMap::new(); - let bundle = proxy.inspect_bundle(&self.url, args).await?; - let bundle: zvariant::Dict = bundle.into(); + pub(super) fn primary(&self) -> Option<&Channel> { + self.0.iter().find(|ch| ch.primary) + } - let compatible = - zvariant_walk_nested_dicts(&bundle, &["update", "compatible"])?.to_owned(); - let version = zvariant_walk_nested_dicts(&bundle, &["update", "version"])?.to_owned(); + #[cfg(not(feature = "demo_mode"))] + fn primary_mut(&mut self) -> Option<&mut Channel> { + self.0.iter_mut().find(|ch| ch.primary) + } - self.bundle = Some(UpstreamBundle::new(compatible, version, slot_status)); + #[cfg(not(feature = "demo_mode"))] + pub(super) fn update_from_poll_status(&mut self, poll_status: zvariant::Dict) -> Result { + let compatible: &zvariant::Str = + zvariant_walk_nested_dicts(&poll_status, &["manifest", "update", "compatible"])?; + let version: &zvariant::Str = + zvariant_walk_nested_dicts(&poll_status, &["manifest", "update", "version"])?; + let manifest_hash: &zvariant::Str = + zvariant_walk_nested_dicts(&poll_status, &["manifest", "manifest-hash"])?; + let effective_url: &zvariant::Str = + zvariant_walk_nested_dicts(&poll_status, &["bundle", "effective-url"])?; + let newer_than_installed: &bool = + zvariant_walk_nested_dicts(&poll_status, &["update-available"])?; + + if let Some(pb) = self.0.iter().find_map(|ch| ch.bundle.as_ref()) { + if compatible == pb.compatible.as_str() + && version == pb.version.as_str() + && manifest_hash == pb.manifest_hash.as_str() + && effective_url == pb.effective_url.as_str() + && *newer_than_installed == pb.newer_than_installed + { + return Ok(false); + } } - Ok(()) - } -} - -impl UpstreamBundle { - fn new(compatible: String, version: String, slot_status: Option<&SlotStatus>) -> Self { - let mut ub = Self { - compatible, - version, - newer_than_installed: false, - }; + self.0.iter_mut().for_each(|ch| ch.bundle = None); - if let Some(slot_status) = slot_status { - ub.update_install(slot_status); + if let Some(primary) = self.primary_mut() { + primary.bundle = Some(UpstreamBundle { + compatible: compatible.as_str().into(), + version: version.as_str().into(), + manifest_hash: manifest_hash.as_str().into(), + effective_url: effective_url.as_str().into(), + newer_than_installed: *newer_than_installed, + }); } - ub - } - - pub(super) fn update_install(&mut self, slot_status: &SlotStatus) { - let slot_0_is_older = slot_status - .get("rootfs_0") - .filter(|r| r.get("boot_status").is_some_and(|b| b == "good")) - .and_then(|r| r.get("bundle_version")) - .and_then(|v| compare_versions(&self.version, v).map(|c| c.is_gt())) - .unwrap_or(true); - - let slot_1_is_older = slot_status - .get("rootfs_1") - .filter(|r| r.get("boot_status").is_some_and(|b| b == "good")) - .and_then(|r| r.get("bundle_version")) - .and_then(|v| compare_versions(&self.version, v).map(|c| c.is_gt())) - .unwrap_or(true); - - self.newer_than_installed = slot_0_is_older && slot_1_is_older; + Ok(true) } } diff --git a/src/dbus/systemd.rs b/src/dbus/systemd.rs index e06f2dd8..2f5f72ea 100644 --- a/src/dbus/systemd.rs +++ b/src/dbus/systemd.rs @@ -66,6 +66,7 @@ pub struct Systemd { pub labgrid: Service, #[allow(dead_code)] pub iobus: Service, + pub rauc: Service, } impl ServiceStatus { @@ -238,6 +239,7 @@ impl Systemd { let networkmanager = Service::new(bb, "network-manager"); let labgrid = Service::new(bb, "labgrid-exporter"); let iobus = Service::new(bb, "lxa-iobus"); + let rauc = Service::new(bb, "rauc"); networkmanager .connect(wtb, conn.clone(), "NetworkManager.service") @@ -248,12 +250,14 @@ impl Systemd { iobus .connect(wtb, conn.clone(), "lxa-iobus.service") .await?; + rauc.connect(wtb, conn.clone(), "rauc.service").await?; Ok(Self { reboot, networkmanager, labgrid, iobus, + rauc, }) } } diff --git a/src/main.rs b/src/main.rs index 203b74b0..29ab5574 100644 --- a/src/main.rs +++ b/src/main.rs @@ -107,9 +107,23 @@ async fn init(screenshooter: ScreenShooter) -> Result<(Ui, WatchedTasksBuilder)> adc.iobus_curr.fast.clone(), adc.iobus_volt.fast.clone(), )?; + + // Set up a http server and provide some static files like the web + // interface and config files that may be edited inside the web ui. + let mut http_server = HttpServer::new(); + + // Allow editing some aspects of the TAC configuration when in "setup mode". + let setup_mode = SetupMode::new(&mut bb, &mut wtb, &mut http_server.server)?; + let (hostname, network, rauc, systemd) = { - let dbus = - DbusSession::new(&mut bb, &mut wtb, led.eth_dut.clone(), led.eth_lab.clone()).await?; + let dbus = DbusSession::new( + &mut bb, + &mut wtb, + led.eth_dut.clone(), + led.eth_lab.clone(), + setup_mode.setup_mode.clone(), + ) + .await?; (dbus.hostname, dbus.network, dbus.rauc, dbus.systemd) }; @@ -123,13 +137,6 @@ async fn init(screenshooter: ScreenShooter) -> Result<(Ui, WatchedTasksBuilder)> // (if requested on start). let watchdog = Watchdog::new(dut_pwr.tick()); - // Set up a http server and provide some static files like the web - // interface and config files that may be edited inside the web ui. - let mut http_server = HttpServer::new(); - - // Allow editing some aspects of the TAC configuration when in "setup mode". - let setup_mode = SetupMode::new(&mut bb, &mut wtb, &mut http_server.server)?; - // Expose a live log of the TAC's systemd journal so it can be viewed // in the web interface. journal::serve(&mut http_server.server); diff --git a/src/motd.rs b/src/motd.rs index c2990d3b..72202298 100644 --- a/src/motd.rs +++ b/src/motd.rs @@ -207,6 +207,7 @@ pub fn run( }, update = channels_events.recv().fuse() => { motd.rauc_update_urls = update? + .into_vec() .into_iter() .filter_map(|ch| { ch.bundle diff --git a/src/ui/screens/diagnostics.rs b/src/ui/screens/diagnostics.rs index 3fd71604..2ab8f639 100644 --- a/src/ui/screens/diagnostics.rs +++ b/src/ui/screens/diagnostics.rs @@ -130,7 +130,7 @@ fn diagnostic_text(ui: &Ui) -> Result { if let Some(channels) = ui.res.rauc.channels.try_get() { write!(&mut text, "chs: ")?; - for ch in channels { + for ch in channels.into_vec() { let en = if ch.enabled { "[x]" } else { "[ ]" }; let name = ch.name; diff --git a/src/ui/screens/update_available.rs b/src/ui/screens/update_available.rs index 4d5e50f6..dddd34ea 100644 --- a/src/ui/screens/update_available.rs +++ b/src/ui/screens/update_available.rs @@ -22,6 +22,7 @@ use async_trait::async_trait; use embedded_graphics::{ mono_font::MonoTextStyle, pixelcolor::BinaryColor, prelude::*, text::Text, }; +use log::error; use serde::{Deserialize, Serialize}; use super::widgets::*; @@ -30,7 +31,7 @@ use super::{ InputEvent, Screen, Ui, }; use crate::broker::Topic; -use crate::dbus::rauc::Channel; +use crate::dbus::rauc::{Channel, Channels, UpdateRequest}; use crate::watched_tasks::WatchedTasksBuilder; const SCREEN_TYPE: AlertScreen = AlertScreen::UpdateAvailable; @@ -73,8 +74,9 @@ impl Selection { !self.channels.is_empty() } - fn update_channels(&self, channels: Vec) -> Option { + fn update_channels(&self, channels: Channels) -> Option { let channels: Vec = channels + .into_vec() .into_iter() .filter(|ch| { ch.bundle @@ -120,9 +122,20 @@ impl Selection { } } - fn perform(&self, alerts: &Arc>, install: &Arc>) { + fn perform(&self, alerts: &Arc>, install: &Arc>) { match self.highlight { - Highlight::Channel(ch) => install.set(self.channels[ch].url.clone()), + Highlight::Channel(ch) => { + if let Some(bundle) = &self.channels[ch].bundle { + let req = UpdateRequest { + url: Some(bundle.effective_url.clone()), + manifest_hash: Some(bundle.manifest_hash.clone()), + }; + + install.set(req); + } else { + error!("Update channel is missing upstream bundle information."); + }; + } Highlight::Dismiss => alerts.deassert(SCREEN_TYPE), } } @@ -135,7 +148,7 @@ pub struct UpdateAvailableScreen { struct Active { widgets: WidgetContainer, alerts: Arc>, - install: Arc>, + install: Arc>, selection: Arc>, } @@ -143,7 +156,7 @@ impl UpdateAvailableScreen { pub fn new( wtb: &mut WatchedTasksBuilder, alerts: &Arc>, - channels: &Arc>>, + channels: &Arc>, ) -> Result { let (mut channels_events, _) = channels.clone().subscribe_unbounded(); let alerts = alerts.clone(); diff --git a/web/src/Setup.tsx b/web/src/Setup.tsx index 816acc52..979b30b9 100644 --- a/web/src/Setup.tsx +++ b/web/src/Setup.tsx @@ -158,13 +158,17 @@ export default function Setup() { When polling for updates the LXA TAC will transmit the following information to our server: The IP address the - request is coming from, the serial number of the device - and information on the currently installed software. + request is coming from, the serial number of the device, + the device uptime and boot id and information on the + currently installed software. Periodically check for updates + + Automatically install and boot updates + ), diff --git a/web/src/TacComponents.tsx b/web/src/TacComponents.tsx index e1c99677..9f18d5fd 100644 --- a/web/src/TacComponents.tsx +++ b/web/src/TacComponents.tsx @@ -115,6 +115,8 @@ type Duration = { type UpstreamBundle = { compatible: string; version: string; + manifest_hash: string; + effective_url: string; newer_than_installed: boolean; }; @@ -125,9 +127,15 @@ type Channel = { url: string; polling_interval?: Duration; enabled: boolean; + primary: boolean; bundle?: UpstreamBundle; }; +type UpdateRequest = { + manifest_hash: string; + url: string; +}; + interface SlotStatusProps { setCmdHint: (hint: React.ReactNode | null) => void; } @@ -253,6 +261,12 @@ export function UpdateConfig() { Periodically check for updates + + Auto Install + + Automatically install and boot updates + + ); @@ -379,6 +393,10 @@ export function UpdateChannels(props: UpdateChannelsProps) { return "Not enabled"; } + if (!e.primary) { + return "Not primary"; + } + if (!e.bundle) { if (enable_polling) { return ; @@ -391,11 +409,16 @@ export function UpdateChannels(props: UpdateChannelsProps) { return "Up to date"; } + const request: UpdateRequest = { + manifest_hash: e.bundle.manifest_hash, + url: e.bundle.effective_url, + }; + return ( Upgrade @@ -528,7 +551,16 @@ export function UpdateNotification() { if (channels !== undefined) { for (let ch of channels) { if (ch.enabled && ch.bundle && ch.bundle.newer_than_installed) { - updates.push(ch); + const request: UpdateRequest = { + manifest_hash: ch.bundle.manifest_hash, + url: ch.bundle.effective_url, + }; + + updates.push({ + name: ch.name, + display_name: ch.display_name, + request: request, + }); } } } @@ -538,7 +570,7 @@ export function UpdateNotification() { key={u.name} iconName="download" topic="/v1/tac/update/install" - send={u.url} + send={u.request} > Install new {u.display_name} bundle