diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index cf77685c4e..9f8a94290c 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -85,10 +85,10 @@ jobs: - name: Install required packages run: zypper --non-interactive install --allow-downgrade clang-devel - libzypp-devel gcc-c++ git libopenssl-3-devel + libzypp-devel make openssl-3 pam-devel @@ -142,13 +142,13 @@ jobs: run: zypper --non-interactive install --allow-downgrade clang-devel dbus-1-daemon - libzypp-devel gcc-c++ git glibc-locale golang-github-google-jsonnet jq libopenssl-3-devel + libzypp-devel make openssl-3 pam-devel @@ -156,6 +156,7 @@ jobs: python3-openapi_spec_validator rustup timezone + util-linux-systemd xkeyboard-config - name: Configure git diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 02517278e1..182643b231 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -236,10 +236,12 @@ dependencies = [ "serde_with", "serde_yaml", "strum", + "tempfile", "thiserror 2.0.17", "tokio", "tokio-stream", "tracing", + "url", "utoipa", "zypp-agama", ] @@ -4300,15 +4302,15 @@ checksum = "bc1ee6eef34f12f765cb94725905c6312b6610ab2b0940889cfe58dae7bc3c72" [[package]] name = "tempfile" -version = "3.20.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", "getrandom 0.3.2", "once_cell", "rustix 1.0.5", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/rust/agama-software/Cargo.toml b/rust/agama-software/Cargo.toml index aedecf13e5..cbcf8e1101 100644 --- a/rust/agama-software/Cargo.toml +++ b/rust/agama-software/Cargo.toml @@ -18,8 +18,10 @@ thiserror = "2.0.12" tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread", "sync"] } tokio-stream = "0.1.16" tracing = "0.1.41" +url = "2.5.7" utoipa = { version = "5.2.0", features = ["axum_extras", "uuid"] } zypp-agama = { path = "../zypp-agama" } [dev-dependencies] serde_yaml = "0.9.34" +tempfile = "3.23.0" diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs index f7d401a618..5528d22ac9 100644 --- a/rust/agama-software/src/model.rs +++ b/rust/agama-software/src/model.rs @@ -21,7 +21,7 @@ use agama_utils::{ actor::Handler, api::{ - software::{Pattern, SoftwareProposal, SystemInfo}, + software::{Repository, SoftwareProposal, SystemInfo}, Issue, }, products::{ProductSpec, UserPattern}, @@ -50,7 +50,7 @@ pub trait ModelAdapter: Send + Sync + 'static { /// Returns the software system information. async fn system_info(&self) -> Result; - async fn compute_proposal(&self) -> Result; + async fn proposal(&self) -> Result; /// Refresh repositories information. async fn refresh(&mut self) -> Result<(), service::Error>; @@ -81,12 +81,16 @@ pub struct Model { selected_product: Option, progress: Handler, question: Handler, + /// Predefined repositories (from the off-line media and Driver Update Disks). + /// They cannot be altered through user configuration. + predefined_repositories: Vec, } impl Model { /// Initializes the struct with the information from the underlying system. pub fn new( zypp_sender: mpsc::UnboundedSender, + predefined_repositories: Vec, progress: Handler, question: Handler, ) -> Result { @@ -95,29 +99,9 @@ impl Model { selected_product: None, progress, question, + predefined_repositories, }) } - - async fn patterns(&self) -> Result, service::Error> { - let Some(product) = &self.selected_product else { - return Err(service::Error::MissingProduct); - }; - - let names = product - .software - .user_patterns - .iter() - .map(|user_pattern| match user_pattern { - UserPattern::Plain(name) => name.clone(), - UserPattern::Preselected(preselected) => preselected.name.clone(), - }) - .collect(); - - let (tx, rx) = oneshot::channel(); - self.zypp_sender - .send(SoftwareAction::GetPatternsMetadata(names, tx))?; - Ok(rx.await??) - } } #[async_trait] @@ -143,11 +127,27 @@ impl ModelAdapter for Model { /// Returns the software system information. async fn system_info(&self) -> Result { - Ok(SystemInfo { - patterns: self.patterns().await?, - repositories: vec![], - addons: vec![], - }) + let Some(product_spec) = self.selected_product.clone() else { + return Err(service::Error::MissingProduct); + }; + + let (tx, rx) = oneshot::channel(); + self.zypp_sender + .send(SoftwareAction::GetSystemInfo(product_spec, tx))?; + let mut system_info = rx.await??; + + // Set "predefined" field as this struct holds the information to determine + // which repositories are "predefined". + let predefined_urls: Vec<_> = self + .predefined_repositories + .iter() + .map(|r| r.url.as_str()) + .collect(); + for repo in system_info.repositories.iter_mut() { + repo.predefined = predefined_urls.contains(&repo.url.as_str()); + } + + Ok(system_info) } async fn refresh(&mut self) -> Result<(), service::Error> { @@ -170,14 +170,14 @@ impl ModelAdapter for Model { Ok(rx.await??) } - async fn compute_proposal(&self) -> Result { + async fn proposal(&self) -> Result { let Some(product_spec) = self.selected_product.clone() else { return Err(service::Error::MissingProduct); }; let (tx, rx) = oneshot::channel(); self.zypp_sender - .send(SoftwareAction::ComputeProposal(product_spec, tx))?; + .send(SoftwareAction::GetProposal(product_spec, tx))?; Ok(rx.await??) } } diff --git a/rust/agama-software/src/model/state.rs b/rust/agama-software/src/model/state.rs index f6a717661b..151b87a227 100644 --- a/rust/agama-software/src/model/state.rs +++ b/rust/agama-software/src/model/state.rs @@ -40,7 +40,7 @@ use crate::{model::software_selection::SoftwareSelection, Resolvable, Resolvable pub struct SoftwareState { pub product: String, pub repositories: Vec, - pub resolvables: Vec, + pub resolvables: Vec, pub options: SoftwareOptions, } @@ -75,23 +75,30 @@ impl<'a> SoftwareStateBuilder<'a> { } /// Adds the user configuration to use. + /// + /// The configuration may contain user-selected patterns and packages, extra repositories, etc. pub fn with_config(mut self, config: &'a Config) -> Self { self.config = Some(config); self } + /// Adds the information of the underlying system. + /// + /// The system may contain repositories, e.g. the off-line medium repository, DUD, etc. pub fn with_system(mut self, system: &'a SystemInfo) -> Self { self.system = Some(system); self } + /// Adds the software selection from the installer. + /// + /// Agama might require the installation of patterns and packages. pub fn with_selection(mut self, selection: &'a SoftwareSelection) -> Self { self.selection = Some(selection); self } - /// Builds the [SoftwareState] by merging the product specification and the - /// user configuration. + /// Builds the [SoftwareState] combining all the sources. pub fn build(self) -> SoftwareState { let mut state = self.from_product_spec(); @@ -113,12 +120,12 @@ impl<'a> SoftwareStateBuilder<'a> { /// Adds the elements from the underlying system. /// /// It searches for repositories in the underlying system. The idea is to - /// use the repositories for off-line installation. + /// use the repositories for off-line installation or Driver Update Disks. fn add_system_config(&self, state: &mut SoftwareState, system: &SystemInfo) { let repositories = system .repositories .iter() - .filter(|r| r.mandatory) + .filter(|r| r.predefined) .map(Repository::from); state.repositories.extend(repositories); } @@ -138,27 +145,29 @@ impl<'a> SoftwareStateBuilder<'a> { match patterns { PatternsConfig::PatternsList(list) => { // Replaces the list, keeping only the non-optional elements. - state.resolvables.retain(|p| p.optional == false); - state.resolvables.extend( - list.iter() - .map(|n| ResolvableState::new(n, ResolvableType::Pattern, false)), - ); + state.resolvables.retain(|p| !p.reason.is_optional()); + state.resolvables.extend(list.iter().map(|n| { + SelectedResolvable::new(n, ResolvableType::Pattern, SelectedReason::User) + })); } PatternsConfig::PatternsMap(map) => { // Adds or removes elements to the list if let Some(add) = &map.add { - state.resolvables.extend( - add.iter() - .map(|n| ResolvableState::new(n, ResolvableType::Pattern, false)), - ); + state.resolvables.extend(add.iter().map(|n| { + SelectedResolvable::new( + n, + ResolvableType::Pattern, + SelectedReason::User, + ) + })); } if let Some(remove) = &map.remove { // NOTE: should we notify when a user wants to remove a // pattern which is not optional? - state - .resolvables - .retain(|p| !(p.optional && remove.contains(&p.resolvable.name))); + state.resolvables.retain(|p| { + !(p.reason.is_optional() && remove.contains(&p.resolvable.name)) + }); } } } @@ -171,9 +180,12 @@ impl<'a> SoftwareStateBuilder<'a> { /// It adds the software selection from Agama modules. fn add_selection(&self, state: &mut SoftwareState, selection: &SoftwareSelection) { - let resolvables = selection - .resolvables() - .map(|r| ResolvableState::new_with_resolvable(&r, false)); + let resolvables = selection.resolvables().map(|r| { + SelectedResolvable::new_with_resolvable( + &r, + SelectedReason::Installer { optional: false }, + ) + }); state.resolvables.extend(resolvables) } @@ -194,27 +206,34 @@ impl<'a> SoftwareStateBuilder<'a> { }) .collect(); - let mut resolvables: Vec = software + let mut resolvables: Vec = software .mandatory_patterns .iter() - .map(|p| ResolvableState::new(p, ResolvableType::Pattern, false)) + .map(|p| { + SelectedResolvable::new( + p, + ResolvableType::Pattern, + SelectedReason::Installer { optional: false }, + ) + }) .collect(); - resolvables.extend( - software - .optional_patterns - .iter() - .map(|p| ResolvableState::new(p, ResolvableType::Pattern, true)), - ); + resolvables.extend(software.optional_patterns.iter().map(|p| { + SelectedResolvable::new( + p, + ResolvableType::Pattern, + SelectedReason::Installer { optional: true }, + ) + })); resolvables.extend(software.user_patterns.iter().filter_map(|p| match p { UserPattern::Plain(_) => None, UserPattern::Preselected(pattern) => { if pattern.selected { - Some(ResolvableState::new( + Some(SelectedResolvable::new( &pattern.name, ResolvableType::Pattern, - true, + SelectedReason::Installer { optional: true }, )) } else { None @@ -279,22 +298,52 @@ impl From<&agama_utils::api::software::Repository> for Repository { /// Defines a resolvable to be selected. #[derive(Debug, PartialEq)] -pub struct ResolvableState { +pub struct SelectedResolvable { /// Resolvable name. pub resolvable: Resolvable, - /// Whether this resolvable is optional or not. - pub optional: bool, + /// The reason to select the resolvable. + pub reason: SelectedReason, } -impl ResolvableState { - pub fn new(name: &str, r#type: ResolvableType, optional: bool) -> Self { - Self::new_with_resolvable(&Resolvable::new(name, r#type), optional) +impl SelectedResolvable { + pub fn new(name: &str, r#type: ResolvableType, reason: SelectedReason) -> Self { + Self::new_with_resolvable(&Resolvable::new(name, r#type), reason) } - pub fn new_with_resolvable(resolvable: &Resolvable, optional: bool) -> Self { + pub fn new_with_resolvable(resolvable: &Resolvable, reason: SelectedReason) -> Self { Self { resolvable: resolvable.clone(), - optional, + reason, + } + } +} + +/// Defines the reason to select a resolvable. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SelectedReason { + /// Selected by the user. + User, + /// Selected by the installer itself and whether it is optional or not. + Installer { optional: bool }, +} + +impl SelectedReason { + pub fn is_optional(&self) -> bool { + if let SelectedReason::Installer { optional } = self { + return *optional; + } + + false + } +} + +impl From for zypp_agama::ResolvableSelected { + fn from(value: SelectedReason) -> Self { + match value { + SelectedReason::User => zypp_agama::ResolvableSelected::User, + SelectedReason::Installer { optional: _ } => { + zypp_agama::ResolvableSelected::Installation + } } } } @@ -320,7 +369,7 @@ mod tests { use crate::model::{ packages::ResolvableType, - state::{ResolvableState, SoftwareStateBuilder}, + state::{SelectedReason, SelectedResolvable, SoftwareStateBuilder}, }; fn build_user_config(patterns: Option) -> Config { @@ -376,8 +425,16 @@ mod tests { assert_eq!( state.resolvables, vec![ - ResolvableState::new("enhanced_base", ResolvableType::Pattern, false), - ResolvableState::new("selinux", ResolvableType::Pattern, true), + SelectedResolvable::new( + "enhanced_base", + ResolvableType::Pattern, + SelectedReason::Installer { optional: false } + ), + SelectedResolvable::new( + "selinux", + ResolvableType::Pattern, + SelectedReason::Installer { optional: true } + ), ] ); } @@ -416,9 +473,17 @@ mod tests { assert_eq!( state.resolvables, vec![ - ResolvableState::new("enhanced_base", ResolvableType::Pattern, false), - ResolvableState::new("selinux", ResolvableType::Pattern, true), - ResolvableState::new("gnome", ResolvableType::Pattern, false) + SelectedResolvable::new( + "enhanced_base", + ResolvableType::Pattern, + SelectedReason::Installer { optional: false } + ), + SelectedResolvable::new( + "selinux", + ResolvableType::Pattern, + SelectedReason::Installer { optional: true } + ), + SelectedResolvable::new("gnome", ResolvableType::Pattern, SelectedReason::User) ] ); } @@ -437,10 +502,10 @@ mod tests { .build(); assert_eq!( state.resolvables, - vec![ResolvableState::new( + vec![SelectedResolvable::new( "enhanced_base", ResolvableType::Pattern, - false + SelectedReason::Installer { optional: false } ),] ); } @@ -460,8 +525,16 @@ mod tests { assert_eq!( state.resolvables, vec![ - ResolvableState::new("enhanced_base", ResolvableType::Pattern, false), - ResolvableState::new("selinux", ResolvableType::Pattern, true) + SelectedResolvable::new( + "enhanced_base", + ResolvableType::Pattern, + SelectedReason::Installer { optional: false } + ), + SelectedResolvable::new( + "selinux", + ResolvableType::Pattern, + SelectedReason::Installer { optional: true } + ) ] ); } @@ -478,8 +551,12 @@ mod tests { assert_eq!( state.resolvables, vec![ - ResolvableState::new("enhanced_base", ResolvableType::Pattern, false), - ResolvableState::new("gnome", ResolvableType::Pattern, false) + SelectedResolvable::new( + "enhanced_base", + ResolvableType::Pattern, + SelectedReason::Installer { optional: false } + ), + SelectedResolvable::new("gnome", ResolvableType::Pattern, SelectedReason::User,) ] ); } @@ -495,7 +572,7 @@ mod tests { name: "install".to_string(), url: "hd:/run/initramfs/install".to_string(), enabled: false, - mandatory: true, + predefined: true, }; let another_repo = Repository { @@ -503,7 +580,7 @@ mod tests { name: "another".to_string(), url: "https://example.lan/SLES/".to_string(), enabled: false, - mandatory: false, + predefined: false, }; let system = SystemInfo { diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index 9d33c25721..d4f254133c 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -36,8 +36,9 @@ use agama_utils::{ progress, question, }; use async_trait::async_trait; -use std::{process::Command, sync::Arc}; -use tokio::sync::{broadcast, Mutex, RwLock}; +use std::{path::PathBuf, process::Command, sync::Arc}; +use tokio::sync::{broadcast, Mutex, MutexGuard, RwLock}; +use url::Url; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -107,6 +108,7 @@ impl Starter { let zypp_sender = ZyppServer::start()?; Arc::new(Mutex::new(Model::new( zypp_sender, + find_mandatory_repositories("/"), self.progress.clone(), self.questions.clone(), )?)) @@ -121,6 +123,7 @@ impl Starter { events: self.events, issues: self.issues, progress: self.progress, + product: None, }; service.setup().await?; Ok(actor::spawn(service)) @@ -140,6 +143,7 @@ pub struct Service { progress: Handler, events: event::Sender, state: Arc>, + product: Option>>, selection: SoftwareSelection, } @@ -161,11 +165,6 @@ impl Service { } pub async fn setup(&mut self) -> Result<(), Error> { - if let Some(install_repo) = find_install_repository() { - tracing::info!("Found repository at {}", install_repo.url); - let mut state = self.state.write().await; - state.system.repositories.push(install_repo); - } Ok(()) } @@ -177,6 +176,92 @@ impl Service { Ok(()) } + + /// Updates the proposal and the service state. + /// + /// This function performs the following actions: + /// + /// 1. Calculates the [wanted state](SoftwareState) using the current product, configuration, + /// system information and product selection. + /// 2. Synchronizes the packaging system (through the model adapter). + /// 3. Emits issues if something is wrong. + /// 4. Updates the service state (system information and proposal). + /// + /// Options from 2 to 4 might take some time, so they run in a separate Tokio task. + async fn update_proposal(&mut self) -> Result<(), Error> { + let Some(product) = &self.product else { + return Ok(()); + }; + + let product = product.read().await.clone(); + + let new_state = { + let state = self.state.read().await; + SoftwareState::build_from(&product, &state.config, &state.system, &self.selection) + }; + + tracing::info!("Wanted software state: {new_state:?}"); + let model = self.model.clone(); + let progress = self.progress.clone(); + let issues = self.issues.clone(); + let state = self.state.clone(); + let events = self.events.clone(); + + tokio::task::spawn(async move { + let mut my_model = model.lock().await; + my_model.set_product(product); + let found_issues = my_model + .write(new_state, progress) + .await + .unwrap_or_else(|e| { + let new_issue = Issue::new( + "software.proposal_failed", + "It was not possible to create a software proposal", + ) + .with_details(&e.to_string()); + vec![new_issue] + }); + _ = issues.cast(issue::message::Set::new(Scope::Software, found_issues)); + + Self::update_state(state, my_model, events).await; + }); + Ok(()) + } + + /// Ancillary function to updates the service state with the information from the model. + /// + /// FIXME: emit events only when the proposal or the system information change. + async fn update_state( + state: Arc>, + model: MutexGuard<'_, dyn ModelAdapter + Send + 'static>, + events: event::Sender, + ) { + let mut state = state.write().await; + + match model.proposal().await { + Ok(proposal) => { + state.proposal.software = Some(proposal); + _ = events.send(Event::ProposalChanged { + scope: Scope::Software, + }); + } + Err(error) => { + tracing::error!("Could not update the software proposal: {error}.") + } + } + + match model.system_info().await { + Ok(system_info) => { + state.system = system_info; + _ = events.send(Event::SystemChanged { + scope: Scope::Software, + }); + } + Err(error) => { + tracing::error!("Could not update the software information: {error}."); + } + } + } } impl Actor for Service { @@ -202,52 +287,18 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler> for Service { async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { - let product = message.product.read().await; + self.product = Some(message.product.clone()); - let software = { + { let mut state = self.state.write().await; state.config = message.config.clone().unwrap_or_default(); - SoftwareState::build_from(&product, &state.config, &state.system, &self.selection) - }; + } self.events.send(Event::ConfigChanged { scope: Scope::Software, })?; - tracing::info!("Wanted software state: {software:?}"); - - let model = self.model.clone(); - let issues = self.issues.clone(); - let events = self.events.clone(); - let progress = self.progress.clone(); - let product_spec = product.clone(); - let state = self.state.clone(); - tokio::task::spawn(async move { - let found_issues = match compute_proposal(model, product_spec, software, progress).await - { - Ok((new_proposal, system_info, found_issues)) => { - let mut state = state.write().await; - state.proposal.software = Some(new_proposal); - state.system = system_info; - found_issues - } - Err(error) => { - let new_issue = Issue::new( - "software.proposal_failed", - "It was not possible to create a software proposal", - ) - .with_details(&error.to_string()); - let mut state = state.write().await; - state.proposal.software = None; - vec![new_issue] - } - }; - - _ = issues.cast(issue::message::Set::new(Scope::Software, found_issues)); - _ = events.send(Event::ProposalChanged { - scope: Scope::Software, - }); - }); + self.update_proposal().await?; Ok(()) } @@ -262,7 +313,7 @@ async fn compute_proposal( let mut my_model = model.lock().await; my_model.set_product(product_spec); let issues = my_model.write(wanted, progress).await?; - let proposal = my_model.compute_proposal().await?; + let proposal = my_model.proposal().await?; let system = my_model.system_info().await?; Ok((proposal, system, issues)) } @@ -303,23 +354,64 @@ impl MessageHandler for Service { impl MessageHandler for Service { async fn handle(&mut self, message: message::SetResolvables) -> Result<(), Error> { self.selection.set(&message.id, message.resolvables); + if self.product.is_some() { + self.update_proposal().await?; + } Ok(()) } } -const LIVE_REPO_DIR: &str = "/run/initramfs/live/install"; +const LIVE_REPO_DIR: &str = "run/initramfs/live/install"; +const DUD_REPO_DIR: &str = "var/lib/agama/dud/repo"; + +/// Returns the local repositories that will be used during installation. +/// +/// By now it considers: +/// +/// * Local repository from the off-line media. +/// * Repository with packages from the Driver Update Disk. +fn find_mandatory_repositories>(root: P) -> Vec { + let base = root.into(); + let mut repos = vec![]; + + let live_repo_dir = base.join(LIVE_REPO_DIR); + if let Some(mut install) = find_repository(&live_repo_dir, "Installation") { + let mount_point = live_repo_dir.display().to_string(); + if let Some(normalized_url) = normalize_repository_url(&mount_point, "/install") { + install.url = normalized_url; + } + repos.push(install); + } + + let dud_repo_dir = base.join(DUD_REPO_DIR); + if let Some(dud) = find_repository(&dud_repo_dir, "AgamaDriverUpdate") { + repos.push(dud) + } + + repos +} -fn find_install_repository() -> Option { - if !std::fs::exists(LIVE_REPO_DIR).is_ok_and(|e| e) { +/// Returns the repository for the given directory if it exists. +fn find_repository(dir: &PathBuf, name: &str) -> Option { + if !std::fs::exists(dir).is_ok_and(|e| e) { return None; } - normalize_repository_url(LIVE_REPO_DIR, "/install").map(|url| Repository { - alias: "install".to_string(), - name: "install".to_string(), - url, + let url_string = format!("dir:{}", dir.display().to_string()); + let Ok(url) = Url::parse(&url_string) else { + tracing::warn!( + "'{}' is not a valid URL. Ignoring the repository.", + url_string + ); + return None; + }; + + Some(Repository { + alias: name.to_string(), + name: name.to_string(), + url: url.to_string(), enabled: true, - mandatory: true, + predefined: true, }) } @@ -351,3 +443,31 @@ fn normalize_repository_url(mount_point: &str, path: &str) -> Option { Some(format!("hd:{mount_point}?device={device}")) } } + +#[cfg(test)] +mod tests { + use crate::service::{find_mandatory_repositories, DUD_REPO_DIR, LIVE_REPO_DIR}; + use tempfile::TempDir; + + #[test] + fn test_find_mandatory_repositories() -> Result<(), Box> { + let tmp_dir = TempDir::with_prefix("test")?; + std::fs::create_dir_all(&tmp_dir.path().join(LIVE_REPO_DIR))?; + std::fs::create_dir_all(&tmp_dir.path().join(DUD_REPO_DIR))?; + + let tmp_dir_str = tmp_dir.as_ref().to_str().unwrap(); + let repositories = find_mandatory_repositories(tmp_dir.as_ref()); + let install = repositories.first().unwrap(); + assert_eq!(&install.alias, "Installation"); + assert!(install + .url + .contains(&format!("hd:{}/{}", tmp_dir_str, LIVE_REPO_DIR))); + assert!(install.predefined); + + let dud = repositories.last().unwrap(); + assert!(dud.url.contains(&format!("/{}", DUD_REPO_DIR))); + assert_eq!(&dud.alias, "AgamaDriverUpdate"); + assert!(dud.predefined); + Ok(()) + } +} diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index 4da749eb40..4f94e06469 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -21,18 +21,19 @@ use agama_utils::{ actor::Handler, api::{ - software::{Pattern, SelectedBy, SoftwareProposal}, + self, + software::{Pattern, SelectedBy, SoftwareProposal, SystemInfo}, Issue, Scope, }, products::ProductSpec, progress, question, }; -use std::path::Path; +use std::{collections::HashMap, path::Path}; use tokio::sync::{ mpsc::{self, UnboundedSender}, oneshot, }; -use zypp_agama::ZyppError; +use zypp_agama::{errors::ZyppResult, ZyppError}; use crate::{ callbacks, @@ -44,8 +45,10 @@ const GPG_KEYS: &str = "/usr/lib/rpm/gnupg/keys/gpg-*"; #[derive(thiserror::Error, Debug)] pub enum ZyppDispatchError { - #[error("Failed to initialize libzypp: {0}")] - InitError(#[from] ZyppError), + #[error(transparent)] + Zypp(#[from] ZyppError), + #[error("libzypp error: {0}")] + ZyppServer(#[from] ZyppServerError), #[error("Response channel closed")] ResponseChannelClosed, #[error("Target creation failed: {0}")] @@ -65,26 +68,11 @@ pub enum ZyppServerError { #[error("Sender error: {0}")] SendError(#[from] mpsc::error::SendError), - #[error("Unknown product: {0}")] - UnknownProduct(String), - - #[error("No selected product")] - NoSelectedProduct, - - #[error("Failed to initialize target directory: {0}")] - TargetInitFailed(#[source] ZyppError), - - #[error("Failed to add a repository: {0}")] - AddRepositoryFailed(#[source] ZyppError), - - #[error("Failed to load the repositories: {0}")] - LoadSourcesFailed(#[source] ZyppError), - - #[error("Listing patterns failed: {0}")] - ListPatternsFailed(#[source] ZyppError), - #[error("Error from libzypp: {0}")] ZyppError(#[from] zypp_agama::ZyppError), + + #[error("Could not find a mount point to calculate the used space")] + MissingMountPoint, } pub type ZyppServerResult = Result; @@ -96,8 +84,8 @@ pub enum SoftwareAction { Handler, ), Finish(oneshot::Sender>), - GetPatternsMetadata(Vec, oneshot::Sender>>), - ComputeProposal( + GetSystemInfo(ProductSpec, oneshot::Sender>), + GetProposal( ProductSpec, oneshot::Sender>, ), @@ -181,8 +169,8 @@ impl ZyppServer { self.write(state, progress, &mut security_callback, tx, zypp) .await?; } - SoftwareAction::GetPatternsMetadata(names, tx) => { - self.get_patterns(names, tx, zypp).await?; + SoftwareAction::GetSystemInfo(product_spec, tx) => { + self.system_info(product_spec, tx, zypp).await?; } SoftwareAction::Install(tx, progress, question) => { let mut download_callback = @@ -194,8 +182,8 @@ impl ZyppServer { SoftwareAction::Finish(tx) => { self.finish(zypp, tx).await?; } - SoftwareAction::ComputeProposal(product_spec, sender) => { - self.compute_proposal(product_spec, sender, zypp).await? + SoftwareAction::GetProposal(product_spec, sender) => { + self.proposal(product_spec, sender, zypp).await? } } Ok(()) @@ -216,7 +204,7 @@ impl ZyppServer { Ok(result) } - fn read(&self, zypp: &zypp_agama::Zypp) -> Result { + fn read(&self, zypp: &zypp_agama::Zypp) -> Result { let repositories = zypp .list_repositories()? .into_iter() @@ -229,7 +217,8 @@ impl ZyppServer { .collect(); let state = SoftwareState { - // FIXME: read the real product. + // FIXME: read the real product. It is not a problem because it is replaced + // later. product: "SLES".to_string(), repositories, resolvables: vec![], @@ -247,12 +236,6 @@ impl ZyppServer { zypp: &zypp_agama::Zypp, ) -> Result<(), ZyppDispatchError> { let mut issues: Vec = vec![]; - // FIXME: - // 1. add and remove the repositories. - // 2. select the patterns. - // 3. select the packages. - // 4. return the proposal and the issues. - // self.add_repositories(state.repositories, tx, &zypp).await?; _ = progress.cast(progress::message::StartWithSteps::new( Scope::Software, @@ -262,7 +245,7 @@ impl ZyppServer { "Calculating the software proposal", ], )); - let old_state = self.read(zypp).unwrap(); + let old_state = self.read(zypp)?; let old_aliases: Vec<_> = old_state .repositories .iter() @@ -333,22 +316,38 @@ impl ZyppServer { zypp.reset_resolvables(); _ = progress.cast(progress::message::Next::new(Scope::Software)); + let result = zypp.select_resolvable( + &state.product, + zypp_agama::ResolvableKind::Product, + zypp_agama::ResolvableSelected::Installation, + ); + if let Err(error) = result { + let message = format!("Could not select the product '{}'", &state.product); + issues.push( + Issue::new("software.select_product", &message).with_details(&error.to_string()), + ); + } for resolvable_state in &state.resolvables { let resolvable = &resolvable_state.resolvable; - // FIXME: we need to distinguish who is selecting the pattern. - // and register an issue if it is not found and it was not optional. let result = zypp.select_resolvable( &resolvable.name, resolvable.r#type.into(), - zypp_agama::ResolvableSelected::Installation, + resolvable_state.reason.into(), ); if let Err(error) = result { - let message = format!("Could not select pattern '{}'", &resolvable.name); - issues.push( - Issue::new("software.select_pattern", &message) - .with_details(&error.to_string()), - ); + if resolvable_state.reason.is_optional() { + tracing::info!( + "Could not select '{}' but it is optional.", + &resolvable.name + ); + } else { + let message = format!("Could not select '{}'", &resolvable.name); + issues.push( + Issue::new("software.select_resolvable", &message) + .with_details(&error.to_string()), + ); + } } } @@ -430,42 +429,75 @@ impl ZyppServer { Ok(()) } - async fn get_patterns( + async fn system_info( &self, - names: Vec, - tx: oneshot::Sender>>, + product: ProductSpec, + tx: oneshot::Sender>, zypp: &zypp_agama::Zypp, ) -> Result<(), ZyppDispatchError> { - let pattern_names = names.iter().map(|n| n.as_str()).collect(); - let patterns = zypp - .patterns_info(pattern_names) - .map_err(ZyppServerError::ListPatternsFailed); - match patterns { - Err(error) => { - tx.send(Err(error)) - .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; - } - Ok(patterns_info) => { - let patterns = patterns_info - .into_iter() - .map(|info| Pattern { - name: info.name, - category: info.category, - description: info.description, - icon: info.icon, - summary: info.summary, - order: info.order, - }) - .collect(); + let patterns = self.patterns(&product, zypp).await?; + let repositories = self.repositories(zypp).await?; - tx.send(Ok(patterns)) - .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; - } - } + let system_info = SystemInfo { + patterns, + repositories, + addons: vec![], + }; + tx.send(Ok(system_info)) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; Ok(()) } + async fn patterns( + &self, + product: &ProductSpec, + zypp: &zypp_agama::Zypp, + ) -> ZyppResult> { + let pattern_names: Vec<_> = product + .software + .user_patterns + .iter() + .map(|p| p.name()) + .collect(); + + let patterns = zypp.patterns_info(pattern_names)?; + + let patterns = patterns + .into_iter() + .map(|p| Pattern { + name: p.name, + category: p.category, + description: p.description, + icon: p.icon, + summary: p.summary, + order: p.order, + }) + .collect(); + Ok(patterns) + } + + async fn repositories( + &self, + zypp: &zypp_agama::Zypp, + ) -> ZyppResult> { + let result = zypp + .list_repositories()? + .into_iter() + .map(|r| api::software::Repository { + alias: r.alias.clone(), + name: r.alias, + url: r.url, + enabled: r.enabled, + // At this point, there is no way to determine if the repository is + // predefined or not. It will be adjusted in the Model::repositories + // function. + predefined: false, + }) + .collect(); + Ok(result) + } + fn initialize_target_dir(&self) -> Result { let target_dir = Path::new(TARGET_DIR); if target_dir.exists() { @@ -497,13 +529,23 @@ impl ZyppServer { } } - async fn compute_proposal( + async fn proposal( &self, - product_spec: ProductSpec, - sender: oneshot::Sender>, + product: ProductSpec, + tx: oneshot::Sender>, zypp: &zypp_agama::Zypp, ) -> Result<(), ZyppDispatchError> { - tracing::info!("Computing software proposal"); + let proposal = SoftwareProposal { + used_space: self.used_space(&zypp).await?, + patterns: self.patterns_selection(&product, &zypp).await?, + }; + + tx.send(Ok(proposal)) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + Ok(()) + } + + async fn used_space(&self, zypp: &zypp_agama::Zypp) -> Result { // TODO: for now it just compute total size, but it can get info about partitions from storage and pass it to libzypp let mount_points = vec![zypp_agama::MountPoint { directory: "/".to_string(), @@ -511,30 +553,26 @@ impl ZyppServer { grow_only: false, // not sure if it has effect as we install everything fresh used_size: 0, }]; - let disk_usage = zypp.count_disk_usage(mount_points); - let Ok(computed_mount_points) = disk_usage else { - sender - .send(Err(disk_usage.unwrap_err().into())) - .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; - return Ok(()); - }; - let size = computed_mount_points.first().unwrap().used_size; - // TODO: format size - let size_str = format!("{size} KiB"); - tracing::info!("Software size: {size_str}"); + let computed_mount_points = zypp.count_disk_usage(mount_points)?; + computed_mount_points + .first() + .map(|m| m.used_size) + .ok_or(ZyppServerError::MissingMountPoint) + } - let pattern_names = product_spec + async fn patterns_selection( + &self, + product: &ProductSpec, + zypp: &zypp_agama::Zypp, + ) -> Result, ZyppServerError> { + let pattern_names = product .software .user_patterns .iter() .map(|p| p.name()) .collect(); let patterns_info = zypp.patterns_info(pattern_names); - - let selected_patterns: Result< - std::collections::HashMap, - ZyppServerError, - > = patterns_info + patterns_info .map(|patterns| { patterns .iter() @@ -549,24 +587,6 @@ impl ZyppServer { }) .collect() }) - .map_err(|e| e.into()); - - tracing::info!("Selected patterns: {selected_patterns:?}"); - let Ok(selected_patterns) = selected_patterns else { - sender - .send(Err(selected_patterns.unwrap_err())) - .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; - return Ok(()); - }; - - let proposal = SoftwareProposal { - size: size_str, - patterns: selected_patterns, - }; - - sender - .send(Ok(proposal)) - .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; - Ok(()) + .map_err(|e| e.into()) } } diff --git a/rust/agama-utils/src/api/software/proposal.rs b/rust/agama-utils/src/api/software/proposal.rs index 1dd60da8b6..abc1607783 100644 --- a/rust/agama-utils/src/api/software/proposal.rs +++ b/rust/agama-utils/src/api/software/proposal.rs @@ -24,6 +24,7 @@ use serde::Serialize; /// Represents the reason why a pattern is selected. #[derive(Clone, Copy, Debug, PartialEq, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] pub enum SelectedBy { /// The pattern was selected by the user. User, @@ -36,9 +37,8 @@ pub enum SelectedBy { #[derive(Clone, Debug, Serialize, utoipa::ToSchema)] /// Software proposal information. pub struct SoftwareProposal { - /// Space required for installation. It is returned as a formatted string which includes - /// a number and a unit (e.g., "GiB"). - pub size: String, + /// Space required for installation in KiB. + pub used_space: i64, /// Patterns selection. It is represented as a hash map where the key is the pattern's name /// and the value why the pattern is selected. pub patterns: HashMap, diff --git a/rust/agama-utils/src/api/software/system_info.rs b/rust/agama-utils/src/api/software/system_info.rs index 6ed077930b..27abf8f7dc 100644 --- a/rust/agama-utils/src/api/software/system_info.rs +++ b/rust/agama-utils/src/api/software/system_info.rs @@ -44,8 +44,8 @@ pub struct Repository { pub url: String, /// Whether the repository is enabled pub enabled: bool, - /// Whether the repository is mandatory (offline base repo, DUD repositories, etc.) - pub mandatory: bool, + /// Whether the repository is predefined (offline base repo, DUD repositories, etc.) + pub predefined: bool, } #[derive(Clone, Debug, Serialize, utoipa::ToSchema)] diff --git a/rust/package/agama.spec b/rust/package/agama.spec index 703d56ff25..d7a1c911de 100644 --- a/rust/package/agama.spec +++ b/rust/package/agama.spec @@ -42,6 +42,9 @@ Requires: dbus-1-common BuildRequires: dbus-1-daemon BuildRequires: clang-devel BuildRequires: pkgconfig(pam) +# includes findmnt +BuildRequires: util-linux-systemd +Requires: util-linux-systemd # required by autoinstallation BuildRequires: jsonnet Requires: jsonnet diff --git a/rust/zypp-agama/zypp-agama-sys/src/bindings.rs b/rust/zypp-agama/zypp-agama-sys/src/bindings.rs index 4b9512136c..62c9aafd28 100644 --- a/rust/zypp-agama/zypp-agama-sys/src/bindings.rs +++ b/rust/zypp-agama/zypp-agama-sys/src/bindings.rs @@ -196,9 +196,9 @@ pub const GPGKeyTrust_GPGKT_REJECT: GPGKeyTrust = 0; pub const GPGKeyTrust_GPGKT_TEMPORARY: GPGKeyTrust = 1; #[doc = " Import key and trust it."] pub const GPGKeyTrust_GPGKT_IMPORT: GPGKeyTrust = 2; -#[doc = " @brief What to do with an unknown GPG key.\n @see KeyRingReport::KeyTrust in libzypp"] +#[doc = " @brief What to do with an unknown GPG key.\n @see zypp::KeyRingReport::KeyTrust in https://github.com/openSUSE/libzypp/blob/master/zypp-logic/zypp/KeyRing.h"] pub type GPGKeyTrust = ::std::os::raw::c_uint; -#[doc = " @brief Callback to decide whether to accept an unknown GPG key.\n @param key_id The ID of the GPG key.\n @param key_name The name of the GPG key.\n @param key_fingerprint The fingerprint of the GPG key.\n @param repository_alias The alias of the repository providing the key. Can be\n an empty string if not available.\n @param user_data User-defined data.\n @return A GPGKeyTrust value indicating the action to take."] +#[doc = " @brief Callback to decide whether to accept an unknown GPG key.\n @param key_id The ID of the GPG key.\n @param key_name The name of the GPG key.\n @param key_fingerprint The fingerprint of the GPG key.\n @param repository_alias The alias of the repository providing the key. Can be\n an empty string if not available.\n @param user_data User-defined data.\n @return A GPGKeyTrust value indicating the action to take.\n @see zypp::KeyRingReport::askUserToAcceptKey in https://github.com/openSUSE/libzypp/blob/master/zypp-logic/zypp/KeyRing.h"] pub type GPGAcceptKeyCallback = ::std::option::Option< unsafe extern "C" fn( key_id: *const ::std::os::raw::c_char, @@ -236,6 +236,7 @@ pub type GPGVerificationFailed = ::std::option::Option< user_data: *mut ::std::os::raw::c_void, ) -> bool, >; +#[doc = " @see zypp::DigestReport in https://github.com/openSUSE/libzypp/blob/master/zypp-logic/zypp/Digest.h"] pub type ChecksumMissing = ::std::option::Option< unsafe extern "C" fn( file: *const ::std::os::raw::c_char, diff --git a/web/src/api/software/proposal.ts b/web/src/api/software/proposal.ts index 02d92d008b..cac63c9e0a 100644 --- a/web/src/api/software/proposal.ts +++ b/web/src/api/software/proposal.ts @@ -23,7 +23,9 @@ import { PatternsSelection } from "~/types/software"; type Proposal = { - size: string; + /** Used space in KiB */ + used_space: number; + /** Selected patterns and the reason */ patterns: PatternsSelection; }; diff --git a/web/src/components/overview/SoftwareSection.tsx b/web/src/components/overview/SoftwareSection.tsx index fb7e993371..d96912d00b 100644 --- a/web/src/components/overview/SoftwareSection.tsx +++ b/web/src/components/overview/SoftwareSection.tsx @@ -25,10 +25,12 @@ import { Content, List, ListItem } from "@patternfly/react-core"; import { isEmpty } from "radashi"; import { _ } from "~/i18n"; import { useProposal, useSystem } from "~/hooks/api"; +import xbytes from "xbytes"; export default function SoftwareSection(): React.ReactNode { const { software: available } = useSystem({ suspense: true }); const { software: proposed } = useProposal({ suspense: true }); + const used_space = xbytes(proposed.used_space * 1024); if (isEmpty(proposed.patterns)) return; const selectedPatternsIds = Object.keys(proposed.patterns); @@ -36,7 +38,7 @@ export default function SoftwareSection(): React.ReactNode { const TextWithoutList = () => { return ( <> - {_("The installation will take")} {proposed.size} + {_("The installation will take")} {used_space} ); }; @@ -50,7 +52,7 @@ export default function SoftwareSection(): React.ReactNode { <> {msg1} - {proposed.size} + {used_space} {msg2}